update
This commit is contained in:
@ -30,6 +30,8 @@ type ArticleService interface {
|
||||
AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
|
||||
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
|
||||
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
|
||||
// GetRunwayDupSources 批量反查走秀重复源(见仓储注释),供编辑页标记重复图。
|
||||
GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
|
||||
// UpdateRunway 审核/编辑后更新字段(字段白名单在仓储层强制)。
|
||||
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// DeleteImage 软删单张走秀图片。
|
||||
@ -237,6 +239,11 @@ func (s *articleService) GetForEdit(ctx context.Context, id uint32) (*model.Bran
|
||||
return rw, orderRunwayImagesForEdit(imgs), nil
|
||||
}
|
||||
|
||||
// GetRunwayDupSources 批量反查走秀重复源(见仓储注释)。
|
||||
func (s *articleService) GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
||||
return s.articles.GetRunwayDupSources(ctx, imageIDs)
|
||||
}
|
||||
|
||||
// orderRunwayImagesForEdit 走秀图片按 parent_image_id 重排为「主图后紧跟其细节图」的扁平顺序。
|
||||
// 同一主图内的细节图按 sort_order 升序;孤儿细节图(parent 缺失)追加末尾,不丢图。
|
||||
func orderRunwayImagesForEdit(imgs []model.BrandRunwayImage) []model.BrandRunwayImage {
|
||||
@ -285,6 +292,12 @@ func (s *articleService) DeleteImage(ctx context.Context, imageID uint32) error
|
||||
if key != "" {
|
||||
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
|
||||
}
|
||||
// 删图后重算本画廊去重:避免删除源图后剩余图仍挂陈旧 dup_of,误显「重复」且源图打不开。
|
||||
if runwayID, err := s.articles.GetRunwayImageRunwayID(ctx, imageID); err == nil && runwayID != 0 {
|
||||
if err := s.articles.ReresolveRunwayDedup(ctx, runwayID); err != nil {
|
||||
log.Printf("[warn] 重算走秀 %d 去重失败(重复标记可能陈旧): %v", runwayID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -19,10 +19,14 @@ type captureIngestRepo struct {
|
||||
repository.IngestRepository
|
||||
createdRW *model.BrandRunway
|
||||
createdImages []model.BrandRunwayImage
|
||||
deletedEntity bool // 模拟实体键已软删(删除即永久:应跳过重爬,不重新写入)
|
||||
}
|
||||
|
||||
func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, error) {
|
||||
return 0, "", false, nil // 实体键未命中 → 新建分支
|
||||
func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, bool, error) {
|
||||
if c.deletedEntity {
|
||||
return 1, model.StatusPending, true, true, nil // 命中但已软删
|
||||
}
|
||||
return 0, "", false, false, nil // 实体键未命中 → 新建分支
|
||||
}
|
||||
|
||||
func (c *captureIngestRepo) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
|
||||
@ -120,3 +124,45 @@ func TestProcessRunwayImageCountEqualsAliveMainRows(t *testing.T) {
|
||||
repo.createdRW.ImageCount, len(p.Looks))
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessRunwaySkipsSoftDeletedEntity 回归「删除即永久」:
|
||||
// 实体键已软删(is_deleted=1)的走秀,重爬时不应被重新写入正式表,仅置任务 done。
|
||||
func TestProcessRunwaySkipsSoftDeletedEntity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
local := storage.NewLocalUploader(dir, "/uploads")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("fake-bytes-for" + r.URL.Path))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// deletedEntity=true:模拟实体键已软删,processRunway 应跳过重爬。
|
||||
repo := &captureIngestRepo{deletedEntity: true}
|
||||
s := &IngestService{
|
||||
repo: repo,
|
||||
brandRepo: fakeBrandRepo{},
|
||||
uploader: local,
|
||||
local: local,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
p := dto.RunwayIngest{
|
||||
Kind: dto.IngestKindRunway,
|
||||
BrandUID: hashid.Encode(1),
|
||||
TitleEn: "deleted-entity-test",
|
||||
Year: 2027,
|
||||
Season: "spring",
|
||||
CollectionType: "rtw",
|
||||
Looks: []dto.RunwayLook{{Main: srv.URL + "/img/a.jpg"}},
|
||||
}
|
||||
job := model.IngestJob{ID: 2, Kind: model.IngestKindCrawl}
|
||||
s.processRunway(context.Background(), job, p)
|
||||
|
||||
if repo.createdRW != nil {
|
||||
t.Fatal("已软删的实体不应被重新写入正式表")
|
||||
}
|
||||
if len(repo.createdImages) != 0 {
|
||||
t.Fatalf("已软删的实体不应写入任何图片行,实际 %d 张", len(repo.createdImages))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -44,7 +44,7 @@ func TestProcessMediaCleanup(t *testing.T) {
|
||||
t.Fatalf("预置文件失败: %v", err)
|
||||
}
|
||||
|
||||
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up)
|
||||
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up, "")
|
||||
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/abc.jpg"]}`}
|
||||
svc.processMediaCleanup(context.Background(), job)
|
||||
|
||||
@ -60,7 +60,7 @@ func TestProcessMediaCleanupSkipsReferenced(t *testing.T) {
|
||||
del := &countingDeleter{LocalUploader: up}
|
||||
|
||||
// 引用计数恒为 1(仍被其他图集使用),不应真删。
|
||||
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up)
|
||||
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up, "")
|
||||
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/shared.jpg"]}`}
|
||||
svc.processMediaCleanup(context.Background(), job)
|
||||
|
||||
|
||||
@ -100,7 +100,7 @@ type KindTab struct {
|
||||
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/CountByStatus;
|
||||
// 3. 在此文件实现本接口并注册到 NewReviewService。
|
||||
//
|
||||
// 列表、详情、通过、驳回、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
|
||||
// 列表、详情、通过、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
|
||||
type ReviewModule interface {
|
||||
Kind() string
|
||||
Label() string
|
||||
@ -110,18 +110,20 @@ type ReviewModule interface {
|
||||
// Approve 把正式记录置为 published;返回记录主键。
|
||||
// 单表发布模型:通过只是改状态,不再晋升重建(图片在入库时已落到正式表)。
|
||||
Approve(ctx context.Context, id uint32, reviewer string) (uint32, error)
|
||||
Reject(ctx context.Context, id uint32, reviewer, reason string) error
|
||||
CountPending(ctx context.Context) (int64, error)
|
||||
}
|
||||
|
||||
// ReviewService 审核的业务逻辑:列出 / 查看 / 微调 / 通过(置 published)/ 驳回。
|
||||
// ReviewService 审核的业务逻辑:列出 / 查看 / 微调 / 通过(置 published)。
|
||||
// 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。
|
||||
//
|
||||
// 注意:审核不再提供「驳回」动作。不想发布的待审项直接走「下架 / 删除」
|
||||
// (articleService / streetSnapService 的 SetDeleted → PurgeGallery),
|
||||
// 会级联软删图片并异步入队清理 S4 孤儿图(按引用计数判定),而非停留在 rejected 态。
|
||||
type ReviewService interface {
|
||||
ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error)
|
||||
RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error)
|
||||
UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error
|
||||
Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error)
|
||||
Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error
|
||||
// DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
|
||||
DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error
|
||||
// AttachImages 把若干张图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
|
||||
@ -234,14 +236,6 @@ func (s *reviewService) Approve(ctx context.Context, kind string, id uint32, rev
|
||||
return m.Approve(ctx, id, reviewer)
|
||||
}
|
||||
|
||||
func (s *reviewService) Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error {
|
||||
m, ok := s.module(kind)
|
||||
if !ok {
|
||||
return repository.ErrNotFound
|
||||
}
|
||||
return m.Reject(ctx, id, reviewer, reason)
|
||||
}
|
||||
|
||||
func (s *reviewService) KindTabs(ctx context.Context) []KindTab {
|
||||
tabs := []KindTab{{Kind: "", Label: "全部"}}
|
||||
var all int64
|
||||
@ -433,16 +427,12 @@ func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[s
|
||||
}
|
||||
|
||||
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 {
|
||||
if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (m *runwayModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
|
||||
return m.repo.SetRecordStatus(ctx, id, model.StatusRejected, reviewer, reason)
|
||||
}
|
||||
|
||||
func (m *runwayModule) CountPending(ctx context.Context) (int64, error) {
|
||||
return m.repo.CountByStatus(ctx, model.StatusPending, &model.BrandRunway{})
|
||||
}
|
||||
@ -566,16 +556,12 @@ func (m *streetModule) UpdateRecord(ctx context.Context, id uint32, fields map[s
|
||||
}
|
||||
|
||||
func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
|
||||
if err := m.repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil {
|
||||
if err := m.repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, reviewer); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (m *streetModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
|
||||
return m.repo.SetStreetRecordStatus(ctx, id, model.StatusRejected, reviewer, reason)
|
||||
}
|
||||
|
||||
func (m *streetModule) CountPending(ctx context.Context) (int64, error) {
|
||||
return m.repo.CountByStatus(ctx, model.StatusPending, &model.StreetSnap{})
|
||||
}
|
||||
|
||||
@ -27,6 +27,8 @@ type StreetSnapService interface {
|
||||
Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error)
|
||||
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
||||
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
|
||||
// GetStreetSnapDupSources 批量反查街拍重复源(见仓储注释),供编辑页标记重复图。
|
||||
GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
|
||||
// UpdateSnap 编辑后更新字段(字段白名单在仓储层强制)。
|
||||
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// DeleteImage 软删单张街拍图片。
|
||||
@ -334,6 +336,11 @@ func (s *streetSnapService) GetForEdit(ctx context.Context, id uint32) (*model.S
|
||||
return snap, orderStreetImagesForEdit(imgs), nil
|
||||
}
|
||||
|
||||
// GetStreetSnapDupSources 批量反查街拍重复源(见仓储注释)。
|
||||
func (s *streetSnapService) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
||||
return s.repo.GetStreetSnapDupSources(ctx, imageIDs)
|
||||
}
|
||||
|
||||
// orderStreetImagesForEdit 把街拍图片重排为「每张主图后紧跟其副图」的扁平顺序。
|
||||
// 同一主图下的副图按 sort_order 升序;父引用指向副图(理论上不发生)的也兜住不丢。
|
||||
func orderStreetImagesForEdit(imgs []model.StreetSnapImage) []model.StreetSnapImage {
|
||||
@ -386,6 +393,12 @@ func (s *streetSnapService) DeleteImage(ctx context.Context, imageID uint32) err
|
||||
if key != "" {
|
||||
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
|
||||
}
|
||||
// 删图后重算本专辑去重:避免删除源图后剩余图仍挂陈旧 dup_of,误显「重复」且源图打不开。
|
||||
if snapID, err := s.repo.GetSnapImageRunwayID(ctx, imageID); err == nil && snapID != 0 {
|
||||
if err := s.repo.ReresolveSnapDedup(ctx, snapID); err != nil {
|
||||
log.Printf("[warn] 重算街拍 %d 去重失败(重复标记可能陈旧): %v", snapID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user