update
This commit is contained in:
@ -16,7 +16,7 @@ import (
|
||||
type ArticleRepository interface {
|
||||
// List 按筛选条件分页查询文章,同时返回符合条件的总数。
|
||||
List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
||||
// ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code/source_url 等管理字段。
|
||||
// ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code 等管理字段。
|
||||
ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
||||
// FindByID 查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
|
||||
FindByID(ctx context.Context, id string) (*model.RunwayRow, error)
|
||||
@ -62,12 +62,11 @@ const articleDetailColumns = `brand_runway.id, brand_runway.title_en, brand_runw
|
||||
brand_runway.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
||||
|
||||
// articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year /
|
||||
// collection_type / season_code / source_url 等管理字段(前端列表不展示这些)。
|
||||
// collection_type / season_code 等管理字段(前端列表不展示这些)。
|
||||
const articleAdminColumns = `brand_runway.id, brand_runway.brand_id,
|
||||
brand_runway.title_en, brand_runway.title_cn,
|
||||
brand_runway.cover, brand_runway.image_count, brand_runway.year,
|
||||
brand_runway.collection_type, brand_runway.season, brand_runway.season_code,
|
||||
brand_runway.source_url,
|
||||
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
||||
|
||||
// brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。
|
||||
@ -232,7 +231,7 @@ func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32)
|
||||
var imgs []model.BrandRunwayImage
|
||||
err := r.db.WithContext(ctx).
|
||||
Select("runway_id, image, name, sort_order").
|
||||
Where("runway_id IN ? AND is_deleted = 0", ids).
|
||||
Where("runway_id IN ? AND is_deleted = 0 AND is_detail = 0", ids).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&imgs).Error
|
||||
if err != nil {
|
||||
|
||||
@ -11,7 +11,11 @@ import (
|
||||
)
|
||||
|
||||
// IngestRepository 爬虫入库管线专属仓储:任务队列(ingest_jobs)+ nonce 防重放
|
||||
// (ingest_nces)+ 走秀正式表写入(brand_runway / brand_runway_images,按 source_url 去重)。
|
||||
// (ingest_nces)+ 走秀正式表写入(brand_runway / brand_runway_images)。
|
||||
//
|
||||
// 去重由 worker 按「实体键」完成:走秀 = brand_id + season_code + collection_type,
|
||||
// 街拍 = city + year(与 review_repository 的 SaveRunwayFromDraft / SaveStreetSnapFromDraft
|
||||
// 晋升时的幂等键一致),因此一个秀/街拍只会在正式表留一行,多来源图片在晋升阶段聚合。
|
||||
//
|
||||
// 入队与领取用同一张 ingest_jobs 表,领取靠 MySQL 的 FOR UPDATE SKIP LOCKED
|
||||
// 实现「多 worker 安全并发」——同一条任务只会被一个 worker 拿到,其它 worker 跳过它。
|
||||
@ -27,37 +31,23 @@ 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)
|
||||
// RunwayIDBySourceURL 按来源链接查是否已存在走秀;返回 (id, found)。
|
||||
RunwayIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error)
|
||||
// CreateRunway 插入走秀正式行,返回自增主键。
|
||||
CreateRunway(ctx context.Context, r *model.BrandRunway) (uint32, error)
|
||||
// CreateRunwayImages 批量插入走秀图片行。
|
||||
CreateRunwayImages(ctx context.Context, imgs []model.BrandRunwayImage) 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
|
||||
// DraftIDBySourceURL 按来源链接查是否已有 pending 草稿;返回 (id, found)。
|
||||
DraftIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error)
|
||||
// StreetSnapIDBySourceURL 按来源链接查是否已存在街拍正式表;返回 (id, found)。
|
||||
StreetSnapIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error)
|
||||
// DraftStreetIDBySourceURL 按来源链接查是否已有 pending 街拍草稿;返回 (id, found)。
|
||||
DraftStreetIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error)
|
||||
// RunwayIDByUnique 按实体键(品牌+季节码+系列)查是否已存在正式走秀;返回 (id, found)。
|
||||
// 多来源爬同品牌同季时应合并为一条,故去重键是实体而非 source_url。
|
||||
RunwayIDByUnique(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error)
|
||||
// DraftRunwayIDByUnique 按实体键查是否已有 pending 走秀草稿;返回 (id, found)。
|
||||
DraftRunwayIDByUnique(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error)
|
||||
// StreetSnapIDByUnique 按实体键(城市+年份)查是否已存在街拍正式表;返回 (id, found)。
|
||||
StreetSnapIDByUnique(ctx context.Context, city string, year uint16) (uint32, bool, error)
|
||||
// DraftStreetSnapIDByUnique 按实体键查是否已有 pending 街拍草稿;返回 (id, found)。
|
||||
DraftStreetSnapIDByUnique(ctx context.Context, city string, year uint16) (uint32, bool, 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
|
||||
// ListJobs 按 id 倒序列出最近的入库任务(用于后台监控页)。
|
||||
ListJobs(ctx context.Context, limit int) ([]model.IngestJob, error)
|
||||
// ListJobs 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。
|
||||
ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, error)
|
||||
// CountJobs 返回 ingest_jobs 总条数(用于分页计算总页数)。
|
||||
CountJobs(ctx context.Context) (int64, error)
|
||||
// RetryJob 把一条 failed 任务重置回 pending,清 last_error/locked_at,等待 worker 重新处理。
|
||||
RetryJob(ctx context.Context, id uint32) error
|
||||
// EnqueueMediaCleanup 写入一条「清理七牛孤儿图」任务(payload 为待清理 key 的 JSON)。
|
||||
@ -66,14 +56,6 @@ type IngestRepository interface {
|
||||
// ScheduleRetry 失败时调用:attempts+1,未达上限则退避后重置 pending,达上限则置 failed。
|
||||
// 用于临时失败(网络抖动 / 单图下载失败)的自动重试,区别于永久失败(payload 解析错等)直接 MarkFailed。
|
||||
ScheduleRetry(ctx context.Context, id uint32, errMsg string) error
|
||||
// ExistsSourceURLs 批量判断一批 source_url 是否已爬取过,返回命中集合(true = 已存在,无需再抓)。
|
||||
//
|
||||
// 语义必须与 worker 判重(processRunway / processStreet)严格一致,否则会出现
|
||||
// 「预检说没有、worker 又判重命中」的重复抓取,或「预检说有、实际已被拒/已删」的漏抓:
|
||||
// - 正式表(brand_runway / street_snap):source_url 命中且 is_deleted = 0
|
||||
// - 草稿表(brand_runway_draft / street_snap_draft):status = pending 且 is_deleted = 0
|
||||
// —— 被审核拒绝(rejected)的草稿不算已存在,允许重新抓取。
|
||||
ExistsSourceURLs(ctx context.Context, urls []string) (map[string]bool, error)
|
||||
}
|
||||
|
||||
type ingestRepository struct {
|
||||
@ -254,17 +236,15 @@ func (r *ingestRepository) ReserveNonce(ctx context.Context, nonce string) (bool
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *ingestRepository) RunwayIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) {
|
||||
if sourceURL == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
// RunwayIDByEntity 按实体键查走秀正式表是否已存在(与 SaveRunwayFromDraft 晋升键一致)。
|
||||
func (r *ingestRepository) RunwayIDByEntity(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) {
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Select("id").
|
||||
Where("source_url = ? AND is_deleted = 0", sourceURL).
|
||||
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", brandID, seasonCode, collectionType).
|
||||
Limit(1).
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
@ -276,26 +256,6 @@ func (r *ingestRepository) RunwayIDBySourceURL(ctx context.Context, sourceURL st
|
||||
return row.ID, true, nil
|
||||
}
|
||||
|
||||
func (r *ingestRepository) CreateRunway(ctx context.Context, rw *model.BrandRunway) (uint32, error) {
|
||||
now := uint32(time.Now().Unix())
|
||||
rw.CreatedAt = now
|
||||
rw.UpdatedAt = now
|
||||
if rw.IsDeleted == 0 {
|
||||
rw.IsDeleted = 0
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(rw).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rw.ID, nil
|
||||
}
|
||||
|
||||
func (r *ingestRepository) CreateRunwayImages(ctx context.Context, imgs []model.BrandRunwayImage) error {
|
||||
if len(imgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(&imgs).Error
|
||||
}
|
||||
|
||||
func (r *ingestRepository) CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error) {
|
||||
now := uint32(time.Now().Unix())
|
||||
d.CreatedAt = now
|
||||
@ -321,117 +281,8 @@ func (r *ingestRepository) CreateRunwayDraftImages(ctx context.Context, imgs []m
|
||||
return r.db.WithContext(ctx).Create(&imgs).Error
|
||||
}
|
||||
|
||||
func (r *ingestRepository) DraftIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) {
|
||||
if sourceURL == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayDraft{}).
|
||||
Select("id").
|
||||
Where("source_url = ? AND status = ? AND is_deleted = 0", sourceURL, model.DraftStatusPending).
|
||||
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
|
||||
}
|
||||
|
||||
func (r *ingestRepository) StreetSnapIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) {
|
||||
if sourceURL == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnap{}).
|
||||
Select("id").
|
||||
Where("source_url = ? AND is_deleted = 0", sourceURL).
|
||||
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
|
||||
}
|
||||
|
||||
func (r *ingestRepository) DraftStreetIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) {
|
||||
if sourceURL == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnapDraft{}).
|
||||
Select("id").
|
||||
Where("source_url = ? AND status = ? AND is_deleted = 0", sourceURL, model.DraftStatusPending).
|
||||
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
|
||||
}
|
||||
|
||||
// RunwayIDByUnique 按实体键(品牌+季节码+系列)查正式走秀,多来源同实体合并为一条。
|
||||
func (r *ingestRepository) RunwayIDByUnique(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) {
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Select("id").
|
||||
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
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
return row.ID, true, nil
|
||||
}
|
||||
|
||||
// DraftRunwayIDByUnique 按实体键查 pending 走秀草稿。
|
||||
func (r *ingestRepository) DraftRunwayIDByUnique(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) {
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayDraft{}).
|
||||
Select("id").
|
||||
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND status = ? AND is_deleted = 0", brandID, seasonCode, collectionType, model.DraftStatusPending).
|
||||
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
|
||||
}
|
||||
|
||||
// StreetSnapIDByUnique 按实体键(城市+年份)查街拍正式表;城市为空则不去重(避免空城市互并)。
|
||||
func (r *ingestRepository) StreetSnapIDByUnique(ctx context.Context, city string, year uint16) (uint32, bool, error) {
|
||||
if city == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
// StreetSnapIDByEntity 按实体键(city + year)查街拍正式表是否已存在(与 SaveStreetSnapFromDraft 晋升键一致)。
|
||||
func (r *ingestRepository) StreetSnapIDByEntity(ctx context.Context, city string, year uint16) (uint32, bool, error) {
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
}
|
||||
@ -450,29 +301,6 @@ func (r *ingestRepository) StreetSnapIDByUnique(ctx context.Context, city string
|
||||
return row.ID, true, nil
|
||||
}
|
||||
|
||||
// DraftStreetSnapIDByUnique 按实体键查 pending 街拍草稿;城市为空则不去重。
|
||||
func (r *ingestRepository) DraftStreetSnapIDByUnique(ctx context.Context, city string, year uint16) (uint32, bool, error) {
|
||||
if city == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnapDraft{}).
|
||||
Select("id").
|
||||
Where("city = ? AND year = ? AND status = ? AND is_deleted = 0", city, year, model.DraftStatusPending).
|
||||
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
|
||||
}
|
||||
|
||||
func (r *ingestRepository) CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error) {
|
||||
now := uint32(time.Now().Unix())
|
||||
d.CreatedAt = now
|
||||
@ -498,18 +326,30 @@ func (r *ingestRepository) CreateStreetSnapDraftImages(ctx context.Context, imgs
|
||||
return r.db.WithContext(ctx).Create(&imgs).Error
|
||||
}
|
||||
|
||||
// ListJobs 按 id 倒序列出最近的入库任务(后台监控页用)。
|
||||
func (r *ingestRepository) ListJobs(ctx context.Context, limit int) ([]model.IngestJob, error) {
|
||||
// ListJobs 按 id 倒序分页列出入库任务(后台监控页用)。offset/limit 控制分页区间。
|
||||
func (r *ingestRepository) ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, error) {
|
||||
if limit < 1 {
|
||||
limit = 200
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
var jobs []model.IngestJob
|
||||
if err := r.db.WithContext(ctx).Order("id DESC").Limit(limit).Find(&jobs).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).Order("id DESC").Offset(offset).Limit(limit).Find(&jobs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// CountJobs 返回 ingest_jobs 总条数(后台监控页分页用)。
|
||||
func (r *ingestRepository) CountJobs(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.IngestJob{}).Count(&n).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// RetryJob 把一条 failed 任务重置回 pending,清掉 last_error / locked_at / next_attempt_at,
|
||||
// 让 worker(每 3s 扫一次 pending)立即重新拉起处理。仅对 failed 生效,其它状态原样不动。
|
||||
func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error {
|
||||
@ -553,70 +393,3 @@ func indexOf(s, sub string) int {
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ExistsSourceURLs 批量判断 source_url 是否已爬取过(预检接口用)。
|
||||
//
|
||||
// 走秀与街拍的正式表、草稿表各查一次 IN,命中即标记。与 worker 判重共用同一套条件,
|
||||
// 保证「爬虫预检跳过」与「worker 判重跳过」判定结果一致。
|
||||
func (r *ingestRepository) ExistsSourceURLs(ctx context.Context, urls []string) (map[string]bool, error) {
|
||||
out := make(map[string]bool)
|
||||
if len(urls) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 去重 + 去空:同一批 URL 可能重复出现,避免无谓的返回行与 SQL 长度。
|
||||
uniq := make([]string, 0, len(urls))
|
||||
seen := make(map[string]struct{}, len(urls))
|
||||
for _, u := range urls {
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[u]; ok {
|
||||
continue
|
||||
}
|
||||
seen[u] = struct{}{}
|
||||
uniq = append(uniq, u)
|
||||
}
|
||||
if len(uniq) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
// 单次请求上限,避免超长 IN 拖慢数据库(超出部分按「未抓过」处理,最多多抓几个,不会漏判已存在的)。
|
||||
const maxBatch = 500
|
||||
if len(uniq) > maxBatch {
|
||||
uniq = uniq[:maxBatch]
|
||||
}
|
||||
|
||||
// 四张表:正式表只看未删除,草稿表只认 pending(被拒草稿允许重抓)。
|
||||
queries := []struct {
|
||||
dest any
|
||||
where string
|
||||
args []any
|
||||
}{
|
||||
{dest: &model.BrandRunway{}, where: "source_url IN ? AND is_deleted = 0"},
|
||||
{dest: &model.StreetSnap{}, where: "source_url IN ? AND is_deleted = 0"},
|
||||
{dest: &model.BrandRunwayDraft{}, where: "source_url IN ? AND status = ? AND is_deleted = 0", args: []any{model.DraftStatusPending}},
|
||||
{dest: &model.StreetSnapDraft{}, where: "source_url IN ? AND status = ? AND is_deleted = 0", args: []any{model.DraftStatusPending}},
|
||||
}
|
||||
|
||||
type hit struct {
|
||||
SourceURL string `gorm:"column:source_url"`
|
||||
}
|
||||
for i := range queries {
|
||||
var rows []hit
|
||||
q := r.db.WithContext(ctx).
|
||||
Model(queries[i].dest).
|
||||
Select("source_url").
|
||||
Where(queries[i].where, append([]any{uniq}, queries[i].args...)...)
|
||||
if err := q.Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
out[row.SourceURL] = true
|
||||
}
|
||||
// 全部命中就无需再查后面的表。
|
||||
if len(out) == len(uniq) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ type ReviewRepository interface {
|
||||
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// SetDraftStatus 设置草稿状态(通过/驳回)与审核人/驳回理由。
|
||||
SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
||||
// SaveRunwayFromDraft 晋升:按 source_url 幂等 upsert 到 brand_runway 正式表并替换图片,返回正式表主键。
|
||||
// SaveRunwayFromDraft 晋升:按实体键(brand_id + season_code + collection_type)幂等 upsert 到 brand_runway 正式表并替换图片,返回正式表主键。
|
||||
SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error)
|
||||
// 街拍草稿镜像方法(与 runway 同构,但对象为 street_snap_draft / street_snap)。
|
||||
ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error)
|
||||
@ -42,8 +42,6 @@ type ReviewRepository interface {
|
||||
UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error
|
||||
SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
||||
SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error)
|
||||
// CountPending 统计 pending 草稿数(合并 runway + street,仪表盘卡片用)。
|
||||
CountPending(ctx context.Context) (int64, error)
|
||||
// CountDraftPending 统计某草稿表(dest 传表对应的零值指针,如 &model.BrandRunwayDraft{})中指定状态的草稿数。
|
||||
// 供各 ReviewModule 复用,新增模块无需再单独写计数方法。
|
||||
CountDraftPending(ctx context.Context, status string, dest any) (int64, error)
|
||||
@ -150,7 +148,7 @@ func (r *reviewRepository) SetDraftStatus(ctx context.Context, id uint32, status
|
||||
}
|
||||
|
||||
// SaveRunwayFromDraft 晋升草稿到正式表:
|
||||
// 1. 按 source_url 查找已存在的正式走秀(幂等:重审同一草稿只更新,不重复建行);
|
||||
// 1. 按实体键(brand_id + season_code + collection_type)查找已存在的正式走秀(幂等:重审同一草稿只更新,不重复建行);
|
||||
// 2. 存在则更新全部可编辑字段 + 图片数;不存在则新建;
|
||||
// 3. 替换图片:软删旧图(is_deleted=1)+ 按草稿图片重建;
|
||||
// 4. 返回正式表主键。
|
||||
@ -176,10 +174,19 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
|
||||
}
|
||||
imgs := unionRunwayDraftImages(curImgs, sibImgs)
|
||||
|
||||
// image_count 仅计主图(is_detail=0):对外列表卡片「N 张」与详情默认画廊都只展示主图,
|
||||
// 细节图作为主图的附属按需展开,不计入主图数。
|
||||
mainCount := 0
|
||||
for _, im := range imgs {
|
||||
if im.IsDetail == 0 {
|
||||
mainCount++
|
||||
}
|
||||
}
|
||||
|
||||
now := uint32(time.Now().Unix())
|
||||
var runwayID uint32
|
||||
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 1) 按 source_url 查找既有正式走秀
|
||||
// 1) 按实体键查找既有正式走秀
|
||||
var existing model.BrandRunway
|
||||
if eErr := tx.Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", draft.BrandID, draft.SeasonCode, draft.CollectionType).
|
||||
Limit(1).Find(&existing).Error; eErr != nil {
|
||||
@ -196,8 +203,7 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
|
||||
"collection_type": draft.CollectionType,
|
||||
"season_code": draft.SeasonCode,
|
||||
"cover": draft.Cover,
|
||||
"source_url": draft.SourceURL,
|
||||
"image_count": uint16(len(imgs)),
|
||||
"image_count": uint16(mainCount),
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
@ -219,8 +225,7 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
|
||||
CollectionType: draft.CollectionType,
|
||||
SeasonCode: draft.SeasonCode,
|
||||
Cover: draft.Cover,
|
||||
SourceURL: draft.SourceURL,
|
||||
ImageCount: uint16(len(imgs)),
|
||||
ImageCount: uint16(mainCount),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
@ -245,6 +250,8 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
|
||||
Image: im.Image,
|
||||
Name: im.Name,
|
||||
SortOrder: uint32(i + 1),
|
||||
LookIndex: im.LookIndex,
|
||||
IsDetail: im.IsDetail,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
@ -261,23 +268,6 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
|
||||
return runwayID, nil
|
||||
}
|
||||
|
||||
func (r *reviewRepository) CountPending(ctx context.Context) (int64, error) {
|
||||
var n1, n2 int64
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayDraft{}).
|
||||
Where("status = ? AND is_deleted = 0", model.DraftStatusPending).
|
||||
Count(&n1).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnapDraft{}).
|
||||
Where("status = ? AND is_deleted = 0", model.DraftStatusPending).
|
||||
Count(&n2).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n1 + n2, nil
|
||||
}
|
||||
|
||||
// CountDraftPending 统计传入表(dest 零值指针)中指定状态的草稿数;供各 ReviewModule 复用。
|
||||
func (r *reviewRepository) CountDraftPending(ctx context.Context, status string, dest any) (int64, error) {
|
||||
var n int64
|
||||
@ -377,7 +367,7 @@ func (r *reviewRepository) SetStreetDraftStatus(ctx context.Context, id uint32,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// SaveStreetSnapFromDraft 晋升街拍草稿到正式表 street_snap(按 source_url 幂等 upsert,替换图片)。
|
||||
// SaveStreetSnapFromDraft 晋升街拍草稿到正式表 street_snap(按实体键 city+year 幂等 upsert,替换图片)。
|
||||
// 逻辑与 SaveRunwayFromDraft 同构,但无 brand_id、有 city,图片明细落到 street_snap_images。
|
||||
// 在事务内完成,保证草稿与正式表一致性。
|
||||
func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error) {
|
||||
@ -412,7 +402,6 @@ func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID
|
||||
"year": draft.Year,
|
||||
"city": draft.City,
|
||||
"cover": draft.Cover,
|
||||
"source_url": draft.SourceURL,
|
||||
"image_count": uint16(len(imgs)),
|
||||
"updated_at": now,
|
||||
}
|
||||
@ -428,7 +417,6 @@ func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID
|
||||
Year: draft.Year,
|
||||
City: draft.City,
|
||||
Cover: draft.Cover,
|
||||
SourceURL: draft.SourceURL,
|
||||
ImageCount: uint16(len(imgs)),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
|
||||
Reference in New Issue
Block a user