43 KiB
入库幂等实现计划(来源级去重 + 街拍实体键引入月份)
面向 AI 代理的工作者: 必需子技能:使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现此计划。步骤使用复选框(
- [ ])语法来跟踪进度。
目标: 让「同一篇来源文章重爬」成为幂等空操作(不下载、不写重复草稿),并修掉街拍实体键 (city, year) 因 year=0 导致的「每城永远一个专辑」缺陷。
架构: 契约层新增 source / source_url / month 三个字段;草稿表落 (source, source_url) 部分唯一索引作为幂等的硬保证;worker 在 process() 中「分派前查重、命中即 MarkDone」;街拍实体键扩为 (city, year, month),四处落点必须同改以保证晋升与去重口径一致。
技术栈: Go 1.26、Gin、GORM + PostgreSQL(pgvector)、ingest_jobs 队列(FOR UPDATE SKIP LOCKED);爬虫侧独立 Go module my-spiders。
规格: docs/superpowers/specs/2026-09-20-ingest-idempotency-design.md
全局约束
- 来源幂等键固定为
(source, source_url);source取值vogue/theimpression。 - 「已存在」判定不限
status、不限is_deleted(决策:爬过即终态,rejected也算)。 - 来源唯一索引必须是部分索引:
WHERE source_url <> ''(存量行source_url为空串,全量唯一索引会因('','')冲突导致建索引失败、启动崩溃)。 - 来源唯一索引不加
is_deleted条件。 - 三个新字段 JSON 名逐字为
source/source_url/month,两侧(internal/dto与spider/internal/ingest)必须一致。 - 街拍实体键
(city, year, month)共 4 处落点,必须同改。 - 街拍
year/month均取抓取时刻,不再从标题解析;(*TheImpressionSpider).parseYear删除(yearRe被vogue.go共用,保留)。 - 公开 API(
PublicStreetSnap等)不加month字段。 - 所有新增列用
not null default ''/not null default 0,不得可空。 - 错误处理:唯一冲突一律按「跳过(
MarkDone)」处理,绝不走failOrRetry。
已完成的准备工作
本次计划不含存量数据清理任务:2026-09-20 已执行完毕(删除 street_snap_drafts 3 行 + street_snap_draft_images 177 行 + ingest_jobs 95–98,S4 孤儿 59 个对象经 media_cleanup 任务清理)。brand_* 与 job 90–94 保留未动。
文件结构
| 文件 | 职责 | 本计划动作 |
|---|---|---|
internal/dto/ingest.go |
爬虫上报载荷的后端定义(跨进程契约) | 修改:加 3 字段 |
internal/dto/ingest_test.go |
契约 JSON 名锁定 | 创建 |
internal/model/runway_draft.go |
走秀草稿模型 | 修改:加 source / source_url |
internal/model/street_snap_draft.go |
街拍草稿模型 | 修改:加 source / source_url / month |
internal/model/street_snap.go |
街拍正式表模型 | 修改:加 month |
internal/database/postgres.go |
连接 + 迁移(AutoMigrate / EnsureDedupSchema) |
修改:加 2 个部分唯一索引 |
internal/repository/ingest_repository.go |
入库仓储(队列 + 草稿写入 + 实体键查询) | 修改:加 ErrDuplicateSource、SourceDraftExists;StreetSnapIDByEntity 加 month |
internal/repository/review_repository.go |
审核仓储(晋升 + 可编辑白名单) | 修改:晋升键加 month、白名单加 month |
internal/service/ingest_service.go |
入库管线业务逻辑 | 修改:来源查重、填来源/月份、冲突兜底 |
internal/service/review_service.go |
审核服务(字段渲染) | 修改:street 字段加 month、卡片副标题加月份 |
internal/repository/source_idempotency_integration_test.go |
索引与查询的集成验证 | 创建(//go:build integration) |
internal/service/ingest_source_dedup_test.go |
来源去重、字段透传、冲突兜底的单元验证 | 创建 |
internal/service/review_service_test.go |
街拍卡片副标题的单元验证 | 创建 |
../spider/internal/ingest/payload.go |
爬虫侧载荷定义 | 修改:加 3 字段 |
../spider/internal/ingest/payload_test.go |
爬虫侧契约锁定 | 创建 |
../spider/internal/spider/vogue.go |
Vogue 爬虫 | 修改:填 source / source_url |
../spider/internal/spider/theimpression.go |
theImpression 街拍爬虫 | 修改:填来源与抓取时间;删 parseYear;抽出可测载荷构造 |
../spider/internal/spider/street_payload_test.go |
街拍载荷构造的单元验证 | 创建 |
任务 1:契约三字段(两侧载荷 + JSON 契约锁定)
文件:
-
修改:
internal/dto/ingest.go:20-35 -
修改:
../spider/internal/ingest/payload.go:18-33 -
测试:创建
internal/dto/ingest_test.go -
测试:创建
../spider/internal/ingest/payload_test.go -
步骤 1:编写失败的后端契约测试
创建 internal/dto/ingest_test.go:
package dto
import (
"encoding/json"
"testing"
)
// TestRunwayIngestSourceFieldsJSON 锁定来源幂等与月份字段的 JSON 名。
// 这三者是与爬虫的跨进程契约:json tag 写错不会编译报错,
// 只会让后端永远收到空串、幂等静默失效,因此用测试钉死。
func TestRunwayIngestSourceFieldsJSON(t *testing.T) {
raw := `{"kind":"street","source":"theimpression","source_url":"https://x/y","month":8}`
var p RunwayIngest
if err := json.Unmarshal([]byte(raw), &p); err != nil {
t.Fatalf("unmarshal 失败: %v", err)
}
if p.Source != "theimpression" {
t.Fatalf("source 未解析: %q", p.Source)
}
if p.SourceURL != "https://x/y" {
t.Fatalf("source_url 未解析: %q", p.SourceURL)
}
if p.Month != 8 {
t.Fatalf("month 未解析: %d", p.Month)
}
}
- 步骤 2:运行测试确认失败
运行:go test ./internal/dto/ -run TestRunwayIngestSourceFieldsJSON -v
预期:FAIL,编译错误 p.Source undefined
- 步骤 3:实现后端字段
修改 internal/dto/ingest.go,在 RunwayIngest 结构体内、Looks 字段之前插入:
Source string `json:"source"` // 来源站标识:vogue / theimpression(可空,来源级幂等键)
SourceURL string `json:"source_url"` // 文章详情页地址(可空;为空时不启用来源去重)
Month uint8 `json:"month"` // 抓取月份 1-12(street 用;runway 不填 = 0)
并把结构体上方注释的「通用字段」段落补两行:
// - Source / SourceURL: 来源站与文章地址,构成来源级幂等键(同一篇文章只入库一次)
// - Month: 抓取月份(街拍实体键的一部分)
- 步骤 4:运行测试确认通过
运行:go test ./internal/dto/ -run TestRunwayIngestSourceFieldsJSON -v
预期:PASS
- 步骤 5:编写失败的爬虫侧契约测试
创建 ../spider/internal/ingest/payload_test.go:
package ingest
import (
"encoding/json"
"strings"
"testing"
)
// TestPayloadSourceFieldsJSON 锁定与后端的 JSON 契约:字段名必须逐字一致,
// 否则后端收到空串、来源去重静默失效。
func TestPayloadSourceFieldsJSON(t *testing.T) {
raw, err := json.Marshal(RunwayIngest{
Kind: KindStreet,
Source: "theimpression",
SourceURL: "https://x/y",
Month: 8,
})
if err != nil {
t.Fatalf("marshal 失败: %v", err)
}
for _, want := range []string{
`"source":"theimpression"`,
`"source_url":"https://x/y"`,
`"month":8`,
} {
if !strings.Contains(string(raw), want) {
t.Fatalf("payload 缺少 %s,实际: %s", want, raw)
}
}
}
- 步骤 6:运行测试确认失败
运行:cd ../spider && go test ./internal/ingest/ -run TestPayloadSourceFieldsJSON -v
预期:FAIL,编译错误 unknown field Source
- 步骤 7:实现爬虫侧字段
修改 ../spider/internal/ingest/payload.go 的 RunwayIngest,在 Looks 之前插入:
Source string `json:"source"` // 来源站标识:vogue / theimpression
SourceURL string `json:"source_url"` // 文章详情页地址(来源级幂等键)
Month uint8 `json:"month"` // 抓取月份 1-12(street 用;runway 不填 = 0)
- 步骤 8:运行测试确认通过
运行:cd ../spider && go test ./internal/ingest/ -v
预期:PASS(含既有 TestSignVerifyRoundtrip)
- 步骤 9:Commit
git add internal/dto/ingest.go internal/dto/ingest_test.go
git commit -m "feat(ingest): 上报载荷新增 source/source_url/month 三字段"
cd ../spider && git add internal/ingest/payload.go internal/ingest/payload_test.go
git commit -m "feat(ingest): 上报载荷新增 source/source_url/month 三字段"
任务 2:Schema(来源两列 + 街拍 month + 两个部分唯一索引)
文件:
-
修改:
internal/model/runway_draft.go:10-30 -
修改:
internal/model/street_snap_draft.go:12-26 -
修改:
internal/model/street_snap.go:9-19 -
修改:
internal/database/postgres.go(EnsureDedupSchema,在最后的return nil之前) -
修改:
internal/repository/ingest_repository.go(本任务一并加入SourceDraftExists,因为测试要覆盖它) -
测试:创建
internal/repository/source_idempotency_integration_test.go -
步骤 1:编写失败的集成测试
创建 internal/repository/source_idempotency_integration_test.go(复用同包 dedup_integration_test.go 已提供的 testDB(t) 与构建标签):
//go:build integration
package repository
import (
"context"
"testing"
"fashionapi/internal/model"
)
// TestSourceIdempotencyIndex 验证部分唯一索引的两条关键语义:
// 1. 同 (source, source_url) 第二次插入必须被拒——这是来源幂等的硬保证;
// 2. source_url 为空串的行不受约束(老爬虫兼容;也是「全量唯一索引会建失败」的原因)。
func TestSourceIdempotencyIndex(t *testing.T) {
db := testDB(t)
newDraft := func(src, url string) *model.StreetSnapDraft {
return &model.StreetSnapDraft{
Title: "itest", City: "itest-city", Year: 2026, Month: 8,
Source: src, SourceURL: url, CreatedAt: 1, UpdatedAt: 1,
}
}
clean := func() { db.Where("title = ?", "itest").Delete(&model.StreetSnapDraft{}) }
clean()
t.Cleanup(clean)
if err := db.Create(newDraft("itest", "itest-url")).Error; err != nil {
t.Fatalf("首次插入应成功: %v", err)
}
if err := db.Create(newDraft("itest", "itest-url")).Error; err == nil {
t.Fatalf("同 (source, source_url) 的第二次插入必须被唯一索引拒绝")
}
if err := db.Create(newDraft("", "")).Error; err != nil {
t.Fatalf("空 source_url 的行不应受部分索引约束: %v", err)
}
if err := db.Create(newDraft("", "")).Error; err != nil {
t.Fatalf("空 source_url 的行应可多行共存: %v", err)
}
}
// TestSourceDraftExists 验证来源查重的口径:空 sourceURL 恒不命中;写入后命中;
// 且不限 status(决策:爬过即终态,rejected 也算)。
func TestSourceDraftExists(t *testing.T) {
db := testDB(t)
repo := NewIngestRepository(db)
ctx := context.Background()
clean := func() { db.Where("title = ?", "itest-exists").Delete(&model.BrandRunwayDraft{}) }
clean()
t.Cleanup(clean)
// 空 source_url:老爬虫路径,恒不命中(不得触发跳过)。
hit, err := repo.SourceDraftExists(ctx, "runway", "itest", "")
if err != nil {
t.Fatalf("SourceDraftExists 出错: %v", err)
}
if hit {
t.Fatalf("空 source_url 不应命中")
}
// 未写入前不命中。
if hit, err = repo.SourceDraftExists(ctx, "runway", "itest", "itest-exists-url"); err != nil || hit {
t.Fatalf("未写入前不应命中: hit=%v err=%v", hit, err)
}
// 写入一条 rejected 草稿:按「爬过即终态」,仍应命中。
row := &model.BrandRunwayDraft{
Title: "itest-exists", Source: "itest", SourceURL: "itest-exists-url",
Status: model.DraftStatusRejected, CreatedAt: 1, UpdatedAt: 1,
}
if err := db.Create(row).Error; err != nil {
t.Fatalf("插入草稿失败: %v", err)
}
if hit, err = repo.SourceDraftExists(ctx, "runway", "itest", "itest-exists-url"); err != nil || !hit {
t.Fatalf("rejected 草稿也应命中: hit=%v err=%v", hit, err)
}
}
- 步骤 2:运行测试确认失败
运行:go test -tags integration ./internal/repository/ -run 'TestSourceIdempotencyIndex|TestSourceDraftExists' -v
预期:FAIL,编译错误 Source field not found in type model.StreetSnapDraft 与 repo.SourceDraftExists undefined
- 步骤 3:给三个模型加列
internal/model/runway_draft.go,在 BrandRunwayDraft 的 JobID 之后插入:
// 来源级幂等键:source=来源站,source_url=文章详情页地址。
// 与 uq_br_draft_source 部分唯一索引(WHERE source_url <> '')配套。
Source string `gorm:"column:source;type:varchar(32);not null;default:''" json:"source"`
SourceURL string `gorm:"column:source_url;type:varchar(512);not null;default:''" json:"source_url"`
internal/model/street_snap_draft.go,在 StreetSnapDraft 的 JobID 之后插入同样两行,并在 Year 之后插入:
// Month 抓取月份 1-12,街拍实体键 (city, year, month) 的一部分。
Month uint8 `gorm:"column:month;not null;default:0" json:"month"`
internal/model/street_snap.go,在 StreetSnap 的 Year 之后插入:
Month uint8 `gorm:"column:month;not null;default:0" json:"month"` // 街拍实体键的一部分
- 步骤 4:加部分唯一索引
修改 internal/database/postgres.go 的 EnsureDedupSchema,在最后的 return nil 之前插入:
// 来源级幂等:同一 (source, source_url) 只允许一行草稿。
// 必须是部分索引——存量草稿 source_url 为空串,全量唯一索引会因大量 ('','') 冲突
// 导致建索引失败、进而启动崩溃;该条件同时让存量数据豁免。
// 不加 is_deleted 条件:按「爬过即终态」的决策,软删也不应解锁幂等。
for _, stmt := range []string{
"CREATE UNIQUE INDEX IF NOT EXISTS uq_br_draft_source " +
"ON brand_runway_drafts (source, source_url) WHERE source_url <> ''",
"CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_draft_source " +
"ON street_snap_drafts (source, source_url) WHERE source_url <> ''",
} {
if err := db.Exec(stmt).Error; err != nil {
return fmt.Errorf("create source idempotency index: %w", err)
}
}
- 步骤 5:加入
SourceDraftExists
在 internal/repository/ingest_repository.go 的 IngestRepository 接口中(紧接 StreetSnapIDByEntity 之后)加入:
// SourceDraftExists 按来源键 (source, source_url) 判断该文章是否已入库过。
// sourceURL 为空时直接返回 false(老爬虫兼容)。不限 status、不限 is_deleted,
// 与 uq_*_draft_source 部分唯一索引口径严格一致(决策:爬过即终态)。
SourceDraftExists(ctx context.Context, kind, source, sourceURL string) (bool, error)
在文件末尾加入实现,并在 import 块加入 "fashionapi/internal/dto"(该包已有 4 个文件 import dto,无循环依赖):
// sourceDraftTables 来源幂等键所在的草稿表(按 payload kind 映射)。
var sourceDraftTables = map[string]string{
dto.IngestKindRunway: "brand_runway_drafts",
dto.IngestKindStreet: "street_snap_drafts",
}
// SourceDraftExists 按 (source, source_url) 判断该文章是否已入库过。
func (r *ingestRepository) SourceDraftExists(ctx context.Context, kind, source, sourceURL string) (bool, error) {
if sourceURL == "" {
return false, nil // 老爬虫不上送来源:不启用来源去重,保持旧行为
}
table, ok := sourceDraftTables[kind]
if !ok {
return false, nil // 未知 kind:防御性放行,交由后续分派报错
}
var n int64
if err := r.db.WithContext(ctx).Table(table).
Where("source = ? AND source_url = ?", source, sourceURL).
Count(&n).Error; err != nil {
return false, err
}
return n > 0, nil
}
- 步骤 6:运行测试确认通过
运行:go test -tags integration ./internal/repository/ -run 'TestSourceIdempotencyIndex|TestSourceDraftExists' -v
预期:PASS(两个测试均通过)
- 步骤 7:回归 + Commit
运行:go vet ./... && go test ./...
预期:vet 无输出;全部包 ok
git add internal/model/runway_draft.go internal/model/street_snap_draft.go \
internal/model/street_snap.go internal/database/postgres.go \
internal/repository/ingest_repository.go \
internal/repository/source_idempotency_integration_test.go
git commit -m "feat(ingest): 草稿表加来源幂等列与部分唯一索引,街拍表加 month 列"
任务 3:worker 前置查重(命中即跳过)
文件:
- 修改:
internal/service/ingest_service.go(process,约 145-166 行) - 测试:创建
internal/service/ingest_source_dedup_test.go
说明:
source_url == ""时不命中来源去重,这一条由任务 2 的TestSourceDraftExists集成测试覆盖(守卫在仓储层)。本任务只测服务层的跳过行为。
- 步骤 1:编写失败的单元测试
创建 internal/service/ingest_source_dedup_test.go:
package service
import (
"context"
"testing"
"fashionapi/internal/model"
"fashionapi/internal/repository"
)
// sourceDedupRepo 只实现来源去重用例涉及的方法;其余由内嵌接口兜底(不会被调用)。
type sourceDedupRepo struct {
repository.IngestRepository
exists bool
done []uint32
}
func (r *sourceDedupRepo) SourceDraftExists(ctx context.Context, kind, source, sourceURL string) (bool, error) {
return r.exists, nil
}
func (r *sourceDedupRepo) MarkDone(ctx context.Context, id uint32) error {
r.done = append(r.done, id)
return nil
}
// spyUploader 记录 Upload 调用次数,用于断言「命中来源去重时一张图都没上传」。
type spyUploader struct{ calls int }
func (u *spyUploader) Upload(ctx context.Context, key string, data []byte, contentType string) (string, error) {
u.calls++
return key, nil
}
func (u *spyUploader) Enabled() bool { return true }
// TestProcessSkipsOnSourceHit 来源幂等命中:任务置 done,且不下载、不写草稿。
func TestProcessSkipsOnSourceHit(t *testing.T) {
repo := &sourceDedupRepo{exists: true}
up := &spyUploader{}
svc := &IngestService{repo: repo, uploader: up}
job := model.IngestJob{
ID: 7,
Kind: model.IngestKindCrawl,
Payload: `{"kind":"street","source":"theimpression",` +
`"source_url":"https://x/y","city":"Copenhagen","images":["https://x/1.jpg"]}`,
}
svc.process(context.Background(), job)
if len(repo.done) != 1 || repo.done[0] != 7 {
t.Fatalf("命中来源去重应 MarkDone(job=7),实际 %v", repo.done)
}
if up.calls != 0 {
t.Fatalf("命中来源去重不应下载/上传任何图片,实际上传 %d 次", up.calls)
}
}
- 步骤 2:运行测试确认失败
运行:go test ./internal/service/ -run TestProcessSkipsOnSourceHit -v
预期:FAIL —— 命中来源去重应 MarkDone(job=7),实际 []
- 步骤 3:在
process()中插入查重
修改 internal/service/ingest_service.go 的 process,在 p.Kind 归一化之后、log.Printf("... 开始处理 ...") 之前插入:
// 来源级幂等:同一篇文章(source + source_url)爬过即终态,直接跳过,不下载、不写草稿。
// 放在 kind 归一化之后、分派之前:单点覆盖 runway / street 两条管线,
// 且早于品牌校验与实体键去重。老爬虫不上送 source_url 时不启用本去重。
if exists, err := s.repo.SourceDraftExists(ctx, p.Kind, p.Source, p.SourceURL); err == nil && exists {
log.Printf("[ingest] job=%d %s 来源去重命中(source=%s url=%s),跳过",
job.ID, p.Kind, p.Source, p.SourceURL)
_ = s.repo.MarkDone(ctx, job.ID)
return
}
- 步骤 4:运行测试确认通过
运行:go test ./internal/service/ -run TestProcessSkipsOnSourceHit -v
预期:PASS
- 步骤 5:回归 + Commit
运行:go vet ./... && go test ./...
预期:vet 无输出;全部包 ok
git add internal/service/ingest_service.go internal/service/ingest_source_dedup_test.go
git commit -m "feat(ingest): worker 处理前按来源键查重,命中即跳过"
任务 4:写草稿填来源/月份 + INSERT 唯一冲突当跳过
文件:
-
修改:
internal/repository/ingest_repository.go(哨兵错误 +CreateRunwayDraft/CreateStreetSnapDraft) -
修改:
internal/service/ingest_service.go(processRunway/processStreet) -
测试:修改
internal/service/ingest_source_dedup_test.go(追加两个用例) -
步骤 1:编写失败的单元测试
在 internal/service/ingest_source_dedup_test.go 末尾追加:
// streetPathRepo 提供 processStreet 走到「写草稿」所需的最小实现,并捕获草稿与错误处理结果。
type streetPathRepo struct {
repository.IngestRepository
got *model.StreetSnapDraft
done []uint32
retried []uint32
failedAt []uint32
createErr error
}
func (r *streetPathRepo) SourceDraftExists(ctx context.Context, kind, source, sourceURL string) (bool, error) {
return false, nil // 前置查重未命中,模拟并发窗口
}
func (r *streetPathRepo) StreetSnapIDByEntity(ctx context.Context, city string, year uint16, month uint8) (uint32, bool, error) {
return 0, false, nil // 实体键未命中,继续下载
}
func (r *streetPathRepo) CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error) {
r.got = d
if r.createErr != nil {
return 0, r.createErr
}
return 42, nil
}
func (r *streetPathRepo) CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error {
return nil
}
func (r *streetPathRepo) MarkDone(ctx context.Context, id uint32) error {
r.done = append(r.done, id)
return nil
}
func (r *streetPathRepo) MarkFailed(ctx context.Context, id uint32, errMsg string) error {
r.failedAt = append(r.failedAt, id)
return nil
}
func (r *streetPathRepo) ScheduleRetry(ctx context.Context, id uint32, errMsg string) error {
r.retried = append(r.retried, id)
return nil
}
// TestProcessStreetFillsSourceAndMonth 草稿须带上来源键与月份(月份是实体键的一部分,
// 漏传会导致所有街拍挤进 month=0 的桶)。
func TestProcessStreetFillsSourceAndMonth(t *testing.T) {
repo := &streetPathRepo{}
svc := &IngestService{repo: repo, uploader: &spyUploader{}}
job := model.IngestJob{
ID: 9,
Kind: model.IngestKindCrawl,
Payload: `{"kind":"street","source":"theimpression","source_url":"https://x/y",` +
`"city":"Copenhagen","year":2026,"month":8,"images":[]}`,
}
svc.process(context.Background(), job)
if repo.got == nil {
t.Fatalf("应写入街拍草稿")
}
if repo.got.Source != "theimpression" || repo.got.SourceURL != "https://x/y" {
t.Fatalf("草稿来源字段未透传: source=%q url=%q", repo.got.Source, repo.got.SourceURL)
}
if repo.got.Month != 8 || repo.got.Year != 2026 {
t.Fatalf("草稿月份/年份未透传: year=%d month=%d", repo.got.Year, repo.got.Month)
}
}
// TestProcessStreetDuplicateDraftTreatedAsSkip 并发下 INSERT 撞唯一索引时,
// 必须按「跳过」处理:MarkDone 且不调度重试(否则会白重试 3 次、堆出假故障)。
func TestProcessStreetDuplicateDraftTreatedAsSkip(t *testing.T) {
repo := &streetPathRepo{createErr: repository.ErrDuplicateSource}
svc := &IngestService{repo: repo, uploader: &spyUploader{}}
job := model.IngestJob{
ID: 10,
Kind: model.IngestKindCrawl,
Payload: `{"kind":"street","source":"theimpression","source_url":"https://x/y",` +
`"city":"Copenhagen","year":2026,"month":8,"images":[]}`,
}
svc.process(context.Background(), job)
if len(repo.done) != 1 || repo.done[0] != 10 {
t.Fatalf("唯一冲突应按跳过处理 MarkDone(10),实际 %v", repo.done)
}
if len(repo.retried) != 0 {
t.Fatalf("唯一冲突不得调度重试,实际 %v", repo.retried)
}
if len(repo.failedAt) != 0 {
t.Fatalf("唯一冲突不得置失败,实际 %v", repo.failedAt)
}
}
- 步骤 2:运行测试确认失败
运行:go test ./internal/service/ -run 'TestProcessStreetFillsSourceAndMonth|TestProcessStreetDuplicateDraftTreatedAsSkip' -v
预期:FAIL —— 草稿来源字段未透传: source="" url=""
- 步骤 3:加哨兵错误并在草稿写入处翻译
修改 internal/repository/ingest_repository.go,在 import 之后、IngestRepository 接口之前加入:
// ErrDuplicateSource 草稿表来源唯一键冲突:同一 (source, source_url) 已存在。
// 由 CreateRunwayDraft / CreateStreetSnapDraft 在检测到重复键时返回,
// 供服务层按「跳过」而非「失败」处理(并发窗口下的兜底)。
var ErrDuplicateSource = errors.New("duplicate source draft")
把 CreateRunwayDraft 的写入改为:
if err := r.db.WithContext(ctx).Create(d).Error; err != nil {
if isDuplicateKey(err) {
return 0, ErrDuplicateSource
}
return 0, err
}
return d.ID, nil
CreateStreetSnapDraft 做同样处理。
- 步骤 4:运行测试确认错误已可识别
运行:go test ./internal/service/ -run 'TestProcessStreetFillsSourceAndMonth|TestProcessStreetDuplicateDraftTreatedAsSkip' -v
预期:FAIL —— 草稿来源字段未透传: source="" url=""(编译已通过,服务层尚未填字段)
- 步骤 5:服务层填来源/月份,并在冲突时跳过
修改 internal/service/ingest_service.go。在 import 块加入 "errors"。
processRunway:在 draft := &model.BrandRunwayDraft{ 字面量内的 JobID: job.ID, 之后加:
Source: p.Source,
SourceURL: p.SourceURL,
并把其下方的错误分支改为:
id, err := s.repo.CreateRunwayDraft(ctx, draft)
if err != nil {
// 唯一冲突=并发下这篇文章已由另一个任务写入草稿。按「跳过」处理,绝不能走 failOrRetry,
// 否则会按指数退避白重试 3 次、在后台堆出一批假故障。
// 此处无需 cleanupUploads:冲突行的图 URL 与本次完全相同 → sha1 内容寻址推出同一对象 key
// → 属覆盖写,不产生孤儿文件。
if errors.Is(err, repository.ErrDuplicateSource) {
log.Printf("[ingest] job=%d runway 来源并发冲突(草稿已存在),跳过", job.ID)
_ = s.repo.MarkDone(ctx, job.ID)
return
}
s.failOrRetry(ctx, job.ID, "create draft: "+err.Error())
return
}
processStreet:草稿字面量内、JobID: job.ID, 之后加:
Source: p.Source,
SourceURL: p.SourceURL,
Month: p.Month,
错误分支改为:
id, err := s.repo.CreateStreetSnapDraft(ctx, draft)
if err != nil {
if errors.Is(err, repository.ErrDuplicateSource) {
log.Printf("[ingest] job=%d street 来源并发冲突(草稿已存在),跳过", job.ID)
_ = s.repo.MarkDone(ctx, job.ID)
return
}
s.failOrRetry(ctx, job.ID, "create street draft: "+err.Error())
return
}
- 步骤 6:运行测试确认通过
运行:go test ./internal/service/ -v
预期:全部 PASS
- 步骤 7:回归 + Commit
运行:go vet ./... && go test ./...
预期:vet 无输出;全部包 ok
git add internal/repository/ingest_repository.go internal/service/ingest_service.go \
internal/service/ingest_source_dedup_test.go
git commit -m "feat(ingest): 草稿落来源与月份字段,唯一冲突按跳过而非失败处理"
任务 5:爬虫侧填充 source / source_url / year / month
文件:
-
修改:
../spider/internal/spider/vogue.go(载荷构造) -
修改:
../spider/internal/spider/theimpression.go -
测试:创建
../spider/internal/spider/street_payload_test.go -
步骤 1:编写失败的街拍载荷测试
创建 ../spider/internal/spider/street_payload_test.go:
package spider
import (
"testing"
"time"
)
// TestBuildStreetPayload 验证街拍载荷的来源键与抓取时间推导:
// year / month 必须同源(同取抓取时刻),否则会产生 (city, 2024, 9) 这类无意义实体键。
func TestBuildStreetPayload(t *testing.T) {
s := &TheImpressionSpider{}
now := time.Date(2026, 8, 14, 10, 30, 0, 0, time.Local)
p := s.buildStreetPayload("The Best Street Style From Copenhagen Fashion Week",
"Copenhagen", []string{"https://x/1.jpg"}, "https://theimpression.com/a/", now)
if p.Source != "theimpression" {
t.Fatalf("source 应为 theimpression,实际 %q", p.Source)
}
if p.SourceURL != "https://theimpression.com/a/" {
t.Fatalf("source_url 未填充: %q", p.SourceURL)
}
if p.Year != 2026 {
t.Fatalf("year 应取抓取年份 2026,实际 %d", p.Year)
}
if p.Month != 8 {
t.Fatalf("month 应取抓取月份 8,实际 %d", p.Month)
}
if p.City != "Copenhagen" || len(p.Images) != 1 {
t.Fatalf("city / images 未正确透传: %+v", p)
}
}
- 步骤 2:运行测试确认失败
运行:cd ../spider && go test ./internal/spider/ -run TestBuildStreetPayload -v
预期:FAIL,编译错误 s.buildStreetPayload undefined
- 步骤 3:实现载荷构造并改
getDetail
修改 ../spider/internal/spider/theimpression.go:
把 getDetail 中的
year := s.parseYear(title)
city := s.parseCity(title)
改为
city := s.parseCity(title)
把上报分支的载荷构造与提交替换为:
p := s.buildStreetPayload(title, city, imageURLs, url, time.Now())
if _, err := s.Ingest.Submit(context.Background(), p); err != nil {
log.Printf("[错误] 街拍入库上报失败 [%s]: %v", title, err)
} else {
log.Printf("[成功] 已上报街拍待入库: %s (共 %d 张图片)", title, len(imageURLs))
}
删除 (*TheImpressionSpider).parseYear 整个方法(已无调用方;yearRe 被 vogue.go 共用,保留)。
在文件末尾加入:
// buildStreetPayload 组装街拍入库载荷。
//
// year 与 month 均取抓取时刻 now,二者必须同源:theImpression 标题实测不含年份,
// 若年份取自标题而月份取自抓取时间,会产出 (city, 2024, 9) 这类无意义实体键。
// now 作为参数传入而非内部调 time.Now(),以便单测固定时间。
func (s *TheImpressionSpider) buildStreetPayload(title, city string, images []string, sourceURL string, now time.Time) ingest.RunwayIngest {
return ingest.RunwayIngest{
Kind: ingest.KindStreet,
Source: "theimpression",
SourceURL: sourceURL,
TitleEn: title,
Year: uint16(now.Year()),
Month: uint8(now.Month()),
City: city,
Images: images,
}
}
并把 getDetail 上方那段已过时的注释替换为:
// 入库分支:启用入库管线(s.Ingest != nil)时,把街拍元数据 HMAC 签名 POST 到后台
// ingest,由后台 worker 异步下载图、传 S4、写 street_snap_draft 待审;通过后才晋升
// street_snap 正式表。判重有两层:来源键 (source, source_url) 挡同篇重爬,
// 实体键 (city, year, month) 挡同城同月的重复收敛。
- 步骤 4:运行测试确认通过
运行:cd ../spider && go test ./internal/spider/ -v
预期:PASS
- 步骤 5:给 vogue 载荷补来源字段
修改 ../spider/internal/spider/vogue.go,在载荷字面量 Kind: ingest.KindRunway, 之后插入:
Source: "vogue",
SourceURL: requestURL,
- 步骤 6:编译并回归
运行:cd ../spider && gofmt -l . && go vet ./... && go test ./...
预期:gofmt 无输出;vet 无输出;全部包 ok
- 步骤 7:Commit
cd ../spider
git add internal/ingest/payload.go internal/ingest/payload_test.go \
internal/spider/vogue.go internal/spider/theimpression.go \
internal/spider/street_payload_test.go
git commit -m "feat(spider): 上送 source/source_url;街拍 year+month 改取抓取时间"
任务 6:街拍实体键引入 month(4 处落点同改)
文件:
-
修改:
internal/repository/ingest_repository.go(落点 1) -
修改:
internal/service/ingest_service.go(落点 2) -
修改:
internal/repository/review_repository.go(落点 3、4) -
测试:修改
internal/repository/source_idempotency_integration_test.go -
步骤 1:编写失败的集成测试
在 internal/repository/source_idempotency_integration_test.go 末尾追加:
// TestStreetSnapEntityKeyIncludesMonth 街拍实体键含月份:
// 同 city + year 但 month 不同不得互判重(否则同城一年两季时装周会整季被丢弃)。
func TestStreetSnapEntityKeyIncludesMonth(t *testing.T) {
db := testDB(t)
repo := NewIngestRepository(db)
ctx := context.Background()
snap := &model.StreetSnap{Title: "itest", City: "itest-city2", Year: 2026, Month: 2, CreatedAt: 1, UpdatedAt: 1}
if err := db.Create(snap).Error; err != nil {
t.Fatalf("插入正式街拍失败: %v", err)
}
t.Cleanup(func() { db.Where("city = ?", "itest-city2").Delete(&model.StreetSnap{}) })
if _, found, err := repo.StreetSnapIDByEntity(ctx, "itest-city2", 2026, 8); err != nil || found {
t.Fatalf("不同月份不应命中: found=%v err=%v", found, err)
}
if id, found, err := repo.StreetSnapIDByEntity(ctx, "itest-city2", 2026, 2); err != nil || !found || id != snap.ID {
t.Fatalf("同月份应命中且返回该行 id=%d: id=%d found=%v err=%v", snap.ID, id, found, err)
}
}
- 步骤 2:运行测试确认失败
运行:go test -tags integration ./internal/repository/ -run TestStreetSnapEntityKeyIncludesMonth -v
预期:FAIL,编译错误 too many arguments in call to repo.StreetSnapIDByEntity
- 步骤 3:落点 1 —— 实体键查询
修改 internal/repository/ingest_repository.go:
接口签名与注释改为:
// StreetSnapIDByEntity 按实体键(city + year + month)查是否已存在街拍正式表;返回 (id, found)。
StreetSnapIDByEntity(ctx context.Context, city string, year uint16, month uint8) (uint32, bool, error)
实现改为:
// StreetSnapIDByEntity 按实体键(city + year + month)查街拍正式表是否已存在
// (与 SaveStreetSnapFromDraft 晋升键一致)。
func (r *ingestRepository) StreetSnapIDByEntity(ctx context.Context, city string, year uint16, month uint8) (uint32, bool, error) {
var row struct {
ID uint32 `gorm:"column:id"`
}
err := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Select("id").
Where("city = ? AND year = ? AND month = ? AND is_deleted = 0", city, year, month).
Limit(1).
Scan(&row).Error
if err != nil {
return 0, false, err
}
if row.ID == 0 {
return 0, false, nil
}
return row.ID, true, nil
}
- 步骤 4:落点 2 —— worker 调用点与日志
修改 internal/service/ingest_service.go 的 processStreet:
// 1) 按实体键(city + year + month)去重:正式表已存在该街拍则跳过,避免重爬重复下载。
// 只判正式表、不判 pending 草稿,保留多来源街拍图片在晋升阶段聚合。
dedupStart := time.Now()
if _, found, err := s.repo.StreetSnapIDByEntity(ctx, p.City, p.Year, p.Month); err == nil && found {
log.Printf("[ingest] job=%d street 实体去重命中(city=%s year=%d month=%d),跳过 耗时=%v",
job.ID, p.City, p.Year, p.Month, time.Since(dedupStart).Round(time.Millisecond))
_ = s.repo.MarkDone(ctx, job.ID)
return
}
- 步骤 5:落点 3 —— 晋升时的正式行查找
修改 internal/repository/review_repository.go 的 SaveStreetSnapFromDraft。
把函数上方注释里的 city+year 改为 city+year+month。正式行查找改为:
if eErr := tx.Where("city = ? AND year = ? AND month = ? AND is_deleted = 0", draft.City, draft.Year, draft.Month).
Limit(1).Find(&existing).Error; eErr != nil {
return eErr
}
common map 增加 month:
common := map[string]any{
"title": draft.Title,
"year": draft.Year,
"month": draft.Month,
"city": draft.City,
"cover": draft.Cover,
"image_count": uint16(len(imgs)),
"updated_at": now,
}
新建分支的结构体加 Month:
snap := &model.StreetSnap{
Title: draft.Title,
Year: draft.Year,
Month: draft.Month,
City: draft.City,
Cover: draft.Cover,
ImageCount: uint16(len(imgs)),
CreatedAt: now,
UpdatedAt: now,
}
- 步骤 6:落点 4 —— sibling 聚合查询(最易漏的一处)
修改 internal/repository/review_repository.go:
SaveStreetSnapFromDraft 内的调用改为:
sibImgs, err := r.streetApprovedSiblingImages(ctx, draftID, draft.City, draft.Year, draft.Month)
函数签名与注释改为:
// streetApprovedSiblingImages 同 city+year+month 已 approved 的其他街拍草稿图(多来源聚合)。
// 月份必须与 SaveStreetSnapFromDraft 的正式行查找口径一致,
// 否则会出现「正式行按月分开了、图片却跨月混在一起」的撕裂。
func (r *reviewRepository) streetApprovedSiblingImages(ctx context.Context, excludeDraftID uint32, city string, year uint16, month uint8) ([]model.StreetSnapDraftImage, error) {
查询条件改为:
Where("city = ? AND year = ? AND month = ? AND status = ? AND is_deleted = 0 AND id <> ?",
city, year, month, model.DraftStatusApproved, excludeDraftID).
- 步骤 7:运行测试确认通过
运行:go test -tags integration ./internal/repository/ -run TestStreetSnapEntityKeyIncludesMonth -v
预期:PASS
- 步骤 8:回归并确认无遗漏落点
运行:go vet ./... && go test ./...
预期:vet 无输出;全部包 ok(编译通过即证明 4 处签名已全部对齐)
再确认没有残留旧口径(rg 不可用则改用编辑器搜索):
rg "city = \? AND year = \?" internal/ --glob '*.go'
rg "StreetSnapIDByEntity" internal/ --glob '*.go'
预期:第一条无匹配;第二条仅剩接口定义、实现、processStreet 调用点三处,且调用均带 4 个实参。
- 步骤 9:Commit
git add internal/repository/ingest_repository.go internal/repository/review_repository.go \
internal/service/ingest_service.go \
internal/repository/source_idempotency_integration_test.go
git commit -m "feat(street): 实体键扩为 city+year+month,四处落点同步"
任务 7:后台审核页展示并允许微调月份
文件:
-
修改:
internal/repository/review_repository.go:337-342(streetDraftEditable) -
修改:
internal/service/review_service.go(toStreetCards、streetModule.DraftDetail) -
测试:创建
internal/service/review_service_test.go -
步骤 1:编写失败的单元测试
创建 internal/service/review_service_test.go:
package service
import (
"testing"
"fashionapi/internal/model"
)
// TestStreetCardsSubtitleHasMonth 街拍审核卡片副标题须带月份,
// 否则同城同年两季时装周在审核列表里无法区分。
func TestStreetCardsSubtitleHasMonth(t *testing.T) {
m := &streetModule{}
cards := m.toStreetCards([]model.StreetSnapDraft{
{ID: 1, Title: "t", City: "Copenhagen", Year: 2026, Month: 8},
})
if len(cards) != 1 {
t.Fatalf("期望 1 张卡片,实际 %d", len(cards))
}
if cards[0].Subtitle != "Copenhagen · 2026-08" {
t.Fatalf("副标题应含月份,实际 %q", cards[0].Subtitle)
}
}
// TestStreetSubtitleNoMonthWhenZero 月份为 0(存量行)时不显示 -00 这种噪音。
func TestStreetSubtitleNoMonthWhenZero(t *testing.T) {
m := &streetModule{}
cards := m.toStreetCards([]model.StreetSnapDraft{{ID: 2, City: "Paris", Year: 2026}})
if cards[0].Subtitle != "Paris · 2026" {
t.Fatalf("零月份应只显示年份,实际 %q", cards[0].Subtitle)
}
}
- 步骤 2:运行测试确认失败
运行:go test ./internal/service/ -run 'TestStreetCardsSubtitle|TestStreetSubtitleNoMonthWhenZero' -v
预期:FAIL —— 副标题应含月份,实际 "Copenhagen"
- 步骤 3:实现副标题
修改 internal/service/review_service.go,把 toStreetCards 整体替换为:
func (m *streetModule) toStreetCards(rows []model.StreetSnapDraft) []DraftCard {
cards := make([]DraftCard, 0, len(rows))
for _, d := range rows {
cards = append(cards, DraftCard{
Kind: dto.IngestKindStreet,
ID: d.ID,
Title: d.Title,
Subtitle: streetSubtitle(d.City, d.Year, d.Month),
Cover: m.img.Compose(d.Cover), // 后台列表封面看展示图
ImageCount: d.ImageCount,
Status: d.Status,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
})
}
return cards
}
// streetSubtitle 组装街拍卡片副标题:城市 · YYYY-MM(月份为 0 时只到年份)。
// 月份是同城两季时装周(同年 2 月 FW / 8 月 SS)唯一的区分手段,故必须显示。
func streetSubtitle(city string, year uint16, month uint8) string {
base := city
if base == "" {
base = "—"
}
if year == 0 {
return base
}
stamp := yearStr(year)
if month > 0 {
stamp = fmt.Sprintf("%s-%02d", stamp, month)
}
return base + " · " + stamp
}
在 import 块加入 "fmt"(若尚未存在)。
- 步骤 4:运行测试确认通过
运行:go test ./internal/service/ -run 'TestStreetCardsSubtitle|TestStreetSubtitleNoMonthWhenZero' -v
预期:两个测试均 PASS
- 步骤 5:把 month 加进审核详情可编辑字段
修改 internal/service/review_service.go 的 streetModule.DraftDetail,把 Fields 替换为:
Fields: []EditField{
{Name: "title", Label: "标题", Value: d.Title, Type: "text"},
{Name: "year", Label: "年份", Value: yearStr(d.Year), Type: "number"},
{Name: "month", Label: "月份", Value: monthStr(d.Month), Type: "number"},
{Name: "city", Label: "城市", Value: d.City, Type: "text"},
},
在文件末尾(yearStr 函数旁)加入:
// monthStr 把 uint8 月份转字符串(0 显示为空)。
func monthStr(m uint8) string {
if m == 0 {
return ""
}
return strconv.FormatUint(uint64(m), 10)
}
- 步骤 6:放行编辑白名单
修改 internal/repository/review_repository.go 的 streetDraftEditable:
// streetDraftEditable 街拍草稿可微调字段白名单(title / year / month / city)。
var streetDraftEditable = map[string]bool{
"title": true,
"year": true,
"month": true,
"city": true,
}
- 步骤 7:回归 + Commit
运行:gofmt -l . && go vet ./... && go test ./...
预期:gofmt 无输出;vet 无输出;全部包 ok
git add internal/service/review_service.go internal/repository/review_repository.go \
internal/service/review_service_test.go
git commit -m "feat(review): 街拍审核页展示月份并允许微调"
完成后的整体验证
cd d:/project/backend && gofmt -l . && go vet ./... && go test ./...—— 无未格式化文件、vet 无输出、全部包 okcd d:/project/backend && go test -tags integration ./internal/repository/ -v—— 集成测试全绿(需先起 pgvector:docker compose -f scripts/pgvector/docker-compose.yml up -d)cd d:/project/spider && gofmt -l . && go vet ./... && go test ./...—— 同上- 启动后端,确认
EnsureDedupSchema未报错(两个部分唯一索引创建成功) - 跑一次
./bin/spider.exe theimpression -n 1,确认新增草稿的source='theimpression'、source_url非空、month为当前月 - 再跑一次同样的命令,确认日志出现「来源去重命中,跳过」,
ingest_jobs新任务status=done,且street_snap_drafts行数不变(这是本次改动的核心验收点)