feat(publish): 爬虫入库直写正式表,驳回行可复用重审

This commit is contained in:
toom1996
2026-09-23 10:21:51 +08:00
parent 4169ebe1ed
commit dba615d636
4 changed files with 365 additions and 118 deletions

View File

@ -14,9 +14,9 @@ import (
// IngestRepository 爬虫入库管线专属仓储:任务队列(ingest_jobs)+ nonce 防重放
// (ingest_nonces)+ 走秀正式表写入(brand_runways / brand_runway_images)。
//
// 去重由 worker 按「实体键」完成:走秀 = brand_id + season_code + collection_type,
// 街拍 = city + year(与 review_repository 的 SaveRunwayFromDraft / SaveStreetSnapFromDraft
// 晋升时的幂等键一致),因此一个秀/街拍只会在正式表留一行,多来源图片在晋升阶段聚合。
// 实体键:走秀 = brand_id + season_code + collection_type,街拍 = city + year。
// 单表发布模型下入库直写正式表(status=pending),实体键命中后按既有行状态分三支:
// pending/published → 放弃本次入库;rejected → 复用该行重审。因此一个秀/街拍始终只留一行。
//
// 入队与领取用同一张 ingest_jobs 表,领取靠 PostgreSQL 的 FOR UPDATE SKIP LOCKED
// 实现「多 worker 安全并发」——同一条任务只会被一个 worker 拿到,其它 worker 跳过它。
@ -32,19 +32,20 @@ type IngestRepository interface {
MarkFailed(ctx context.Context, id uint32, errMsg string) error
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
ReserveNonce(ctx context.Context, nonce string) (ok bool, err error)
// RunwayIDByEntity 按实体键(brand_id + season_code + collection_type)查是否已存在走秀正式表;
// 返回 (id, found)。worker 据此跳过已晋升秀的重爬,避免重复下载。
RunwayIDByEntity(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error)
// CreateRunwayDraft 插入走秀草稿行(status=pending),返回自增主键。
CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error)
// CreateRunwayDraftImages 批量插入草稿图片行。
CreateRunwayDraftImages(ctx context.Context, imgs []model.BrandRunwayDraftImage) error
// StreetSnapIDByEntity 按实体键(city + year)查是否已存在街拍正式表;返回 (id, found)。
StreetSnapIDByEntity(ctx context.Context, city string, year uint16) (uint32, bool, error)
// CreateStreetSnapDraft 插入街拍草稿行(status=pending),返回自增主键。
CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error)
// CreateStreetSnapDraftImages 批量插入街拍草稿图片行。
CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error
// 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
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,返回命中行 id(仅取最近一条)。
FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (dupID uint32, found bool, err error)
// ListJobs 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。
@ -239,94 +240,167 @@ func (r *ingestRepository) ReserveNonce(ctx context.Context, nonce string) (bool
return true, nil
}
// RunwayIDByEntity 按实体键查走秀正式表是否已存在(与 SaveRunwayFromDraft 晋升键一致)。
func (r *ingestRepository) RunwayIDByEntity(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) {
// ---- 入库直写正式表(单表发布模型)----
func (r *ingestRepository) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, error) {
var row struct {
ID uint32 `gorm:"column:id"`
ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
}
err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Select("id").
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
return 0, "", false, err
}
if row.ID == 0 {
return 0, false, nil
return 0, "", false, nil
}
return row.ID, true, nil
return row.ID, row.Status, true, nil
}
func (r *ingestRepository) CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error) {
func (r *ingestRepository) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
now := uint32(time.Now().Unix())
d.CreatedAt = now
d.UpdatedAt = now
if d.Status == "" {
d.Status = model.DraftStatusPending
rw.CreatedAt, rw.UpdatedAt = now, now
if rw.Status == "" {
rw.Status = model.StatusPending
}
if err := r.db.WithContext(ctx).Create(d).Error; err != nil {
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 d.ID, nil
return rw.ID, nil
}
func (r *ingestRepository) CreateRunwayDraftImages(ctx context.Context, imgs []model.BrandRunwayDraftImage) error {
if len(imgs) == 0 {
return nil
}
func (r *ingestRepository) ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error {
now := uint32(time.Now().Unix())
for i := range imgs {
imgs[i].CreatedAt = now
imgs[i].UpdatedAt = now
}
return r.db.WithContext(ctx).Create(&imgs).Error
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
})
}
// StreetSnapIDByEntity 按实体键(city + year)查街拍正式表是否已存在(与 SaveStreetSnapFromDraft 晋升键一致)。
func (r *ingestRepository) StreetSnapIDByEntity(ctx context.Context, city string, year uint16) (uint32, bool, error) {
func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16) (uint32, string, bool, error) {
var row struct {
ID uint32 `gorm:"column:id"`
ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
}
err := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Select("id").
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
return 0, "", false, err
}
if row.ID == 0 {
return 0, false, nil
return 0, "", false, nil
}
return row.ID, true, nil
return row.ID, row.Status, true, nil
}
func (r *ingestRepository) CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error) {
func (r *ingestRepository) CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error) {
now := uint32(time.Now().Unix())
d.CreatedAt = now
d.UpdatedAt = now
if d.Status == "" {
d.Status = model.DraftStatusPending
snap.CreatedAt, snap.UpdatedAt = now, now
if snap.Status == "" {
snap.Status = model.StatusPending
}
if err := r.db.WithContext(ctx).Create(d).Error; err != nil {
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 d.ID, nil
return snap.ID, nil
}
func (r *ingestRepository) CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error {
if len(imgs) == 0 {
return nil
}
func (r *ingestRepository) ReuseRejectedStreetSnap(ctx context.Context, id uint32, snap *model.StreetSnap, imgs []model.StreetSnapImage) error {
now := uint32(time.Now().Unix())
for i := range imgs {
imgs[i].CreatedAt = now
imgs[i].UpdatedAt = now
}
return r.db.WithContext(ctx).Create(&imgs).Error
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
})
}
// ListJobs 按 id 倒序分页列出入库任务(后台监控页用)。offset/limit 控制分页区间。