# 入库幂等实现计划(来源级去重 + 街拍实体键引入月份) > **面向 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`: ```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` 字段之前插入: ```go Source string `json:"source"` // 来源站标识:vogue / theimpression(可空,来源级幂等键) SourceURL string `json:"source_url"` // 文章详情页地址(可空;为空时不启用来源去重) Month uint8 `json:"month"` // 抓取月份 1-12(street 用;runway 不填 = 0) ``` 并把结构体上方注释的「通用字段」段落补两行: ```go // - Source / SourceURL: 来源站与文章地址,构成来源级幂等键(同一篇文章只入库一次) // - Month: 抓取月份(街拍实体键的一部分) ``` - [ ] **步骤 4:运行测试确认通过** 运行:`go test ./internal/dto/ -run TestRunwayIngestSourceFieldsJSON -v` 预期:PASS - [ ] **步骤 5:编写失败的爬虫侧契约测试** 创建 `../spider/internal/ingest/payload_test.go`: ```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` 之前插入: ```go 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** ```bash 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 //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` 之后插入: ```go // 来源级幂等键: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` 之后插入: ```go // Month 抓取月份 1-12,街拍实体键 (city, year, month) 的一部分。 Month uint8 `gorm:"column:month;not null;default:0" json:"month"` ``` `internal/model/street_snap.go`,在 `StreetSnap` 的 `Year` 之后插入: ```go Month uint8 `gorm:"column:month;not null;default:0" json:"month"` // 街拍实体键的一部分 ``` - [ ] **步骤 4:加部分唯一索引** 修改 `internal/database/postgres.go` 的 `EnsureDedupSchema`,在最后的 `return nil` 之前插入: ```go // 来源级幂等:同一 (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` 之后)加入: ```go // 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,无循环依赖): ```go // 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 ```bash 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`: ```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("... 开始处理 ...")` **之前**插入: ```go // 来源级幂等:同一篇文章(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 ```bash 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` 末尾追加: ```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` 接口之前加入: ```go // ErrDuplicateSource 草稿表来源唯一键冲突:同一 (source, source_url) 已存在。 // 由 CreateRunwayDraft / CreateStreetSnapDraft 在检测到重复键时返回, // 供服务层按「跳过」而非「失败」处理(并发窗口下的兜底)。 var ErrDuplicateSource = errors.New("duplicate source draft") ``` 把 `CreateRunwayDraft` 的写入改为: ```go 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,` 之后加: ```go Source: p.Source, SourceURL: p.SourceURL, ``` 并把其下方的错误分支改为: ```go 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,` 之后加: ```go Source: p.Source, SourceURL: p.SourceURL, Month: p.Month, ``` 错误分支改为: ```go 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 ```bash 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`: ```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` 中的 ```go year := s.parseYear(title) city := s.parseCity(title) ``` 改为 ```go city := s.parseCity(title) ``` 把上报分支的载荷构造与提交替换为: ```go 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` 共用,保留)。 在文件末尾加入: ```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` 上方那段已过时的注释替换为: ```go // 入库分支:启用入库管线(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,` 之后插入: ```go Source: "vogue", SourceURL: requestURL, ``` - [ ] **步骤 6:编译并回归** 运行:`cd ../spider && gofmt -l . && go vet ./... && go test ./...` 预期:gofmt 无输出;vet 无输出;全部包 ok - [ ] **步骤 7:Commit** ```bash 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` 末尾追加: ```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`: 接口签名与注释改为: ```go // StreetSnapIDByEntity 按实体键(city + year + month)查是否已存在街拍正式表;返回 (id, found)。 StreetSnapIDByEntity(ctx context.Context, city string, year uint16, month uint8) (uint32, bool, error) ``` 实现改为: ```go // 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`: ```go // 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`。正式行查找改为: ```go 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`: ```go 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`: ```go 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` 内的调用改为: ```go sibImgs, err := r.streetApprovedSiblingImages(ctx, draftID, draft.City, draft.Year, draft.Month) ``` 函数签名与注释改为: ```go // streetApprovedSiblingImages 同 city+year+month 已 approved 的其他街拍草稿图(多来源聚合)。 // 月份必须与 SaveStreetSnapFromDraft 的正式行查找口径一致, // 否则会出现「正式行按月分开了、图片却跨月混在一起」的撕裂。 func (r *reviewRepository) streetApprovedSiblingImages(ctx context.Context, excludeDraftID uint32, city string, year uint16, month uint8) ([]model.StreetSnapDraftImage, error) { ``` 查询条件改为: ```go 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` 不可用则改用编辑器搜索): ```bash rg "city = \? AND year = \?" internal/ --glob '*.go' rg "StreetSnapIDByEntity" internal/ --glob '*.go' ``` 预期:第一条无匹配;第二条仅剩接口定义、实现、`processStreet` 调用点三处,且调用均带 4 个实参。 - [ ] **步骤 9:Commit** ```bash 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`: ```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` 整体替换为: ```go 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` 替换为: ```go 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` 函数旁)加入: ```go // 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`: ```go // 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 ```bash 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 无输出、全部包 ok - [ ] `cd 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` **行数不变**(这是本次改动的核心验收点)