update
This commit is contained in:
@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"sync"
|
||||
@ -44,7 +45,28 @@ type IngestService struct {
|
||||
// uploader: 主上传器(S4或本地)
|
||||
// del: 删除器(S4或本地),用于 media_cleanup 真删S4/本地孤儿文件
|
||||
// local: 本地兜底上传器(S4上传失败时回退,避免图片完全丢失)
|
||||
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader) *IngestService {
|
||||
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader, downloadProxy string) *IngestService {
|
||||
// 下载代理:download_proxy(或 INGEST_DOWNLOAD_PROXY)非空时,下载客户端经该代理访问源站;
|
||||
// 仅作用于图片下载,S4 上传走独立 s3 客户端、pgx 数据库连接不走 http,均不受影响。
|
||||
// 留空回退 http.ProxyFromEnvironment(默认直连,仍可被系统 HTTP(S)_PROXY 影响)。
|
||||
dlTransport := &http.Transport{
|
||||
MaxIdleConns: imageFetchConcurrency * 4,
|
||||
MaxIdleConnsPerHost: imageFetchConcurrency * 2,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
}
|
||||
if downloadProxy != "" {
|
||||
if pu, perr := url.Parse(downloadProxy); perr == nil {
|
||||
dlTransport.Proxy = http.ProxyURL(pu)
|
||||
log.Printf("[ingest] 图片下载走代理: %s", downloadProxy)
|
||||
} else {
|
||||
log.Printf("[ingest] download_proxy 非法,已忽略并回退 ProxyFromEnvironment: %q", downloadProxy)
|
||||
dlTransport.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
} else {
|
||||
dlTransport.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
|
||||
return &IngestService{
|
||||
repo: repo,
|
||||
brandRepo: brandRepo,
|
||||
@ -57,14 +79,8 @@ func NewIngestService(repo repository.IngestRepository, brandRepo repository.Bra
|
||||
httpClient: &http.Client{
|
||||
// 超时须显著大于单张图的正常下载耗时(实测 4~22s,且随源站波动剧烈)。
|
||||
// 原 30s 余量太薄:一次抖动就会让「单图失败=整任务失败」把整批打回重试。
|
||||
Timeout: 60 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
MaxIdleConns: imageFetchConcurrency * 4,
|
||||
MaxIdleConnsPerHost: imageFetchConcurrency * 2,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
},
|
||||
Timeout: 60 * time.Second,
|
||||
Transport: dlTransport,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -243,8 +259,14 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
}
|
||||
|
||||
// 2) 实体键查重并决定分支(单表模型)
|
||||
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
|
||||
reuseID, existStatus, exist, err := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType)
|
||||
// 标题缺年份(p.Year=0)时,用发布日期的年份补全年份,使 year / season_code 不再为空,
|
||||
// 避免这些秀在「按年份/季节筛选」与热门排序(按 year + season_code)中丢失。
|
||||
effYear := p.Year
|
||||
if effYear == 0 && p.PublishedAt != 0 {
|
||||
effYear = uint16(time.Unix(int64(p.PublishedAt), 0).UTC().Year())
|
||||
}
|
||||
seasonCode := season.Derive(effYear, p.CollectionType, p.Season)
|
||||
reuseID, existStatus, exist, isDeleted, err := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType)
|
||||
if err != nil {
|
||||
// 读失败绝不能当成「未命中」:误判为新建会走一次注定冲突的写入——实体键唯一索引
|
||||
//(2026-09-22-03)会把它拒成一次可重试的写库错误,重试时实体键已能查到该行(pending)
|
||||
@ -253,9 +275,38 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 删除即永久:已软删的实体键不再因重爬复活(原设计靠部分唯一索引释放键、允许重爬,已改为禁止)。
|
||||
if exist && isDeleted {
|
||||
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
return
|
||||
}
|
||||
if !exist && p.Year == 0 {
|
||||
// 标题无年份的历史行(year=0、season_code 为空)待回填:按旧键再查一次,
|
||||
// 命中后走跳过分支按主键 id 回填(回填后season_code变为effYear码,后续重跑即可直接命中,幂等)。
|
||||
var rid uint32
|
||||
rid, existStatus, exist, isDeleted, err = s.repo.RunwayEntityState(ctx, brandID, "", p.CollectionType)
|
||||
if err != nil {
|
||||
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
||||
return
|
||||
}
|
||||
reuseID = rid
|
||||
if exist && isDeleted {
|
||||
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
action := decideIngestAction(exist, existStatus)
|
||||
if action == ingestSkip {
|
||||
log.Printf("[ingest] job=%d runway 实体已存在(status=%s),跳过", job.ID, existStatus)
|
||||
log.Printf("[ingest] job=%d runway 实体已存在(status=%s),跳过(回填 published_at)", job.ID, existStatus)
|
||||
// 临时回填:仅更新发布时间与缺失年份,不重下图片(图片已在库)。
|
||||
if p.PublishedAt != 0 {
|
||||
if err := s.repo.BackfillRunwayPublishedAt(ctx, reuseID, p.PublishedAt, effYear, seasonCode); err != nil {
|
||||
s.failOrRetry(ctx, job.ID, "backfill published_at: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
return
|
||||
}
|
||||
@ -311,10 +362,11 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
TitleCn: p.TitleCn,
|
||||
DescriptionEn: p.DescriptionEn,
|
||||
DescriptionCn: p.DescriptionCn,
|
||||
Year: p.Year,
|
||||
Year: effYear,
|
||||
Season: p.Season,
|
||||
CollectionType: p.CollectionType,
|
||||
SeasonCode: seasonCode,
|
||||
PublishedAt: p.PublishedAt,
|
||||
Cover: cover,
|
||||
ImageCount: imageCount,
|
||||
Status: model.StatusPending,
|
||||
@ -445,12 +497,18 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
// 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。
|
||||
// 标题先经 NormalizeStreetTitle 归一化(空白折叠),消除「纯空白微调」造成的重复实体(设计规格 §12.4)。
|
||||
title := repository.NormalizeStreetTitle(p.TitleEn)
|
||||
reuseID, existStatus, exist, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, title)
|
||||
reuseID, existStatus, exist, isDeleted, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, title)
|
||||
if err != nil {
|
||||
// 同 runway:读失败不得当「未命中」,否则会给同一实体留两行。fail-closed + 退避重试。
|
||||
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 删除即永久:已软删的实体键不再因重爬复活(与 runway 同源)。
|
||||
if exist && isDeleted {
|
||||
log.Printf("[ingest] job=%d street 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
return
|
||||
}
|
||||
action := decideIngestAction(exist, existStatus)
|
||||
if action == ingestSkip {
|
||||
log.Printf("[ingest] job=%d street 实体已存在(status=%s),跳过", job.ID, existStatus)
|
||||
|
||||
Reference in New Issue
Block a user