update
This commit is contained in:
@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
@ -23,7 +24,7 @@ type MediaCleanupEnqueuer interface {
|
||||
// ArticleService 文章业务接口。
|
||||
type ArticleService interface {
|
||||
List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error)
|
||||
Detail(ctx context.Context, id string, locale string, hd bool) (*dto.PublicArticleDetail, error)
|
||||
Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error)
|
||||
// AdminList 后台管理列表:返回含季节 / 年份 / 系列等管理字段的走秀项。
|
||||
AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
|
||||
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
|
||||
@ -49,9 +50,9 @@ type articleService struct {
|
||||
}
|
||||
|
||||
// NewArticleService 创建文章服务。
|
||||
// img 负责把库里存储的七牛 key 拼装为可访问 URL(按 tier 决定普通/高清);
|
||||
// media + deleter 用于删除图集时清理七牛孤儿文件(引用计数归零才真删);
|
||||
// cleaner 把「删七牛」动作异步入队(由入库 worker 处理),避免阻塞删除请求。
|
||||
// img 负责把库里存储的 S4 key 拼装为可访问 URL(统一走展示样式,VIP 与免费同质量);
|
||||
// media + deleter 用于删除图集时清理孤儿文件(引用计数归零才真删);
|
||||
// cleaner 把「删存储」动作异步入队(由入库 worker 处理),避免阻塞删除请求。
|
||||
func NewArticleService(articles repository.ArticleRepository, media repository.MediaRepository, img *imgurl.Composer, deleter storage.Deleter, cleaner MediaCleanupEnqueuer) ArticleService {
|
||||
return &articleService{articles: articles, media: media, img: img, deleter: deleter, cleaner: cleaner}
|
||||
}
|
||||
@ -73,7 +74,7 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu
|
||||
UID: hashid.EncodeTyped(hashid.TypeRunway, r.ID),
|
||||
BrandUID: hashid.Encode(r.BrandID),
|
||||
Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale),
|
||||
Cover: s.img.Compose(r.Cover, false), // 列表封面统一普通图
|
||||
Cover: s.img.ComposeThumb(r.Cover), // 列表封面走缩略图样式(小尺寸省流量)
|
||||
BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"), // 品牌名强制优先英文,保持英文观感
|
||||
ImageCount: r.ImageCount,
|
||||
})
|
||||
@ -88,7 +89,7 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu
|
||||
}
|
||||
|
||||
// AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 /
|
||||
// 来源链接的管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。
|
||||
// 管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。
|
||||
func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) {
|
||||
q.Normalize()
|
||||
rows, total, err := s.articles.ListAdmin(ctx, q)
|
||||
@ -107,7 +108,6 @@ func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]d
|
||||
CollectionType: r.CollectionType,
|
||||
SeasonCode: r.SeasonCode,
|
||||
ImageCount: r.ImageCount,
|
||||
SourceURL: r.SourceURL,
|
||||
})
|
||||
}
|
||||
return list, total, nil
|
||||
@ -125,8 +125,9 @@ func (s *articleService) attachImages(ctx context.Context, list []dto.PublicArti
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 列表缩略图统一普通图;再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。
|
||||
imgs := s.toPublicImages(raw, false)
|
||||
// 列表缩略图走缩略图样式(与卡片封面同样式,故去重比较仍成立);
|
||||
// 再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。
|
||||
imgs := s.toThumbImages(raw)
|
||||
imgs = excludeCover(imgs, list[i].Cover)
|
||||
if len(imgs) > limit {
|
||||
imgs = imgs[:limit]
|
||||
@ -152,8 +153,8 @@ func excludeCover(imgs []dto.PublicArticleImage, cover string) []dto.PublicArtic
|
||||
}
|
||||
|
||||
// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言;
|
||||
// hd=true 时返回高清原图(VIP),false 时所有图拼普通样式(免费/未登录)。
|
||||
func (s *articleService) Detail(ctx context.Context, id string, locale string, hd bool) (*dto.PublicArticleDetail, error) {
|
||||
// 图片统一走展示样式(VIP 与免费同质量)。
|
||||
func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
|
||||
row, err := s.articles.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
@ -167,16 +168,40 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string, h
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
|
||||
// 默认仅主图(is_detail=0)进 Images;细节图按 look_index 分组进 Details,
|
||||
// 供前端「细节入口」按需展开。这样既满足「默认只展示主图」,又不丢失细节图数据。
|
||||
mains := make([]model.BrandRunwayImage, 0, len(imgs))
|
||||
byLook := make(map[uint32][]model.BrandRunwayImage)
|
||||
for _, im := range imgs {
|
||||
if im.IsDetail == 0 {
|
||||
mains = append(mains, im)
|
||||
} else {
|
||||
byLook[im.LookIndex] = append(byLook[im.LookIndex], im)
|
||||
}
|
||||
}
|
||||
details := make([]dto.RunwayLookDetails, 0, len(byLook))
|
||||
lookIdxs := make([]uint32, 0, len(byLook))
|
||||
for k := range byLook {
|
||||
lookIdxs = append(lookIdxs, k)
|
||||
}
|
||||
sort.Slice(lookIdxs, func(i, j int) bool { return lookIdxs[i] < lookIdxs[j] })
|
||||
for _, k := range lookIdxs {
|
||||
details = append(details, dto.RunwayLookDetails{
|
||||
LookIndex: k,
|
||||
Images: s.toPublicImages(byLook[k]),
|
||||
})
|
||||
}
|
||||
|
||||
return &dto.PublicArticleDetail{
|
||||
UID: hashid.EncodeTyped(hashid.TypeRunway, row.ID),
|
||||
BrandUID: hashid.Encode(row.BrandID),
|
||||
Title: pickLocale(row.TitleEn, row.TitleCn, locale),
|
||||
Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale),
|
||||
Cover: s.img.Compose(row.Cover, hd),
|
||||
Cover: s.img.Compose(row.Cover),
|
||||
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
||||
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
|
||||
SourceURL: row.SourceURL,
|
||||
Images: s.toPublicImages(imgs, hd),
|
||||
Images: s.toPublicImages(mains),
|
||||
Details: details,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -241,20 +266,43 @@ func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IDs 返回全部文章的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。
|
||||
// toPublicImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
|
||||
// ID 用 TypeRunwayImage 前缀编码,与文章 uid(r 前缀)区分开,供单图收藏定位。
|
||||
// hd=true 时返回高清原图(VIP),false 时拼普通样式(免费/未登录)。
|
||||
func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage, hd bool) []dto.PublicArticleImage {
|
||||
// Image 走展示样式(high);额外给出 Thumb(缩略图样式),供大图灯箱左栏缩略图条与右下角
|
||||
// 细节缩略图等小尺寸场景使用——那些位置只显示几十到一百多像素,用大图纯属浪费带宽。
|
||||
// 注意:列表用的 toThumbImages 不填 Thumb(列表的 Image 本身就是缩略图)。
|
||||
func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage) []dto.PublicArticleImage {
|
||||
if len(imgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]dto.PublicArticleImage, 0, len(imgs))
|
||||
for _, im := range imgs {
|
||||
out = append(out, dto.PublicArticleImage{
|
||||
ID: hashid.EncodeTyped(hashid.TypeRunwayImage, im.ID),
|
||||
Image: s.img.Compose(im.Image, hd),
|
||||
Name: im.Name,
|
||||
ID: hashid.EncodeTyped(hashid.TypeRunwayImage, im.ID),
|
||||
Image: s.img.Compose(im.Image),
|
||||
Thumb: s.img.ComposeThumb(im.Image),
|
||||
Name: im.Name,
|
||||
IsDetail: im.IsDetail,
|
||||
LookIndex: im.LookIndex,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toThumbImages 与 toPublicImages 同构,但图片地址走「列表缩略图」样式(ComposeThumb),
|
||||
// 仅供列表卡片 / 图条等小尺寸场景使用;详情页与大图灯箱仍用 toPublicImages(展示样式)。
|
||||
func (s *articleService) toThumbImages(imgs []model.BrandRunwayImage) []dto.PublicArticleImage {
|
||||
if len(imgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]dto.PublicArticleImage, 0, len(imgs))
|
||||
for _, im := range imgs {
|
||||
out = append(out, dto.PublicArticleImage{
|
||||
ID: hashid.EncodeTyped(hashid.TypeRunwayImage, im.ID),
|
||||
Image: s.img.ComposeThumb(im.Image),
|
||||
Name: im.Name,
|
||||
IsDetail: im.IsDetail,
|
||||
LookIndex: im.LookIndex,
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@ -21,6 +21,7 @@ import (
|
||||
// - Login 签发两者;access 用于日常请求,refresh 仅在续期时使用。
|
||||
// - Refresh 用 refresh token 换发新 access(refresh 复用、不轮换,足够内部工具)。
|
||||
// - Logout 吊销当前 refresh;RevokeAllByRefresh 吊销该用户全部 refresh(踢下线)。
|
||||
//
|
||||
// 由于 refresh 落库,服务端得以单方面作废会话——这是纯无状态 JWT 做不到的。
|
||||
type AuthService interface {
|
||||
Me(ctx context.Context, userID uint32) (*dto.UserPayload, error)
|
||||
|
||||
@ -27,7 +27,6 @@ func NewError(status int, msg string) *Error {
|
||||
|
||||
func badRequest(msg string) *Error { return NewError(http.StatusBadRequest, msg) }
|
||||
func unauthorized(msg string) *Error { return NewError(http.StatusUnauthorized, msg) }
|
||||
func conflict(msg string) *Error { return NewError(http.StatusConflict, msg) }
|
||||
func internalErr(msg string) *Error { return NewError(http.StatusInternalServerError, msg) }
|
||||
|
||||
// ErrArticleNotFound 文章不存在,文案与原项目一致。
|
||||
|
||||
@ -55,7 +55,7 @@ func (s *indexService) Runway(ctx context.Context, locale string) (IndexRunwayDa
|
||||
ArticleUID: hashid.Encode(b.ArticleID),
|
||||
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
||||
Brand: pickLocale(b.BrandEn, b.BrandCn, "en"),
|
||||
Cover: s.img.Compose(b.Cover, false), // 首页封面统一普通图
|
||||
Cover: s.img.Compose(b.Cover), // 首页热门卡片为大图展示,用高清样式(high)
|
||||
Title: pickLocale(b.TitleEn, b.TitleCn, locale),
|
||||
})
|
||||
}
|
||||
|
||||
@ -62,32 +62,28 @@ func (s *IngestService) Submit(ctx context.Context, payload dto.RunwayIngest) (u
|
||||
if err != nil {
|
||||
return 0, NewError(http.StatusBadRequest, "payload invalid: "+err.Error())
|
||||
}
|
||||
if payload.SourceURL == "" {
|
||||
return 0, NewError(http.StatusBadRequest, "source_url required")
|
||||
}
|
||||
// runway 必须带品牌;street 不关联品牌,brand_uid 可选
|
||||
if payload.Kind == dto.IngestKindRunway && payload.BrandUID == "" {
|
||||
return 0, NewError(http.StatusBadRequest, "brand_uid required")
|
||||
}
|
||||
job := &model.IngestJob{SourceURL: payload.SourceURL, Payload: string(raw), Kind: model.IngestKindCrawl}
|
||||
job := &model.IngestJob{Payload: string(raw), Kind: model.IngestKindCrawl}
|
||||
if err := s.repo.Enqueue(ctx, job); err != nil {
|
||||
return 0, internalErr(err.Error())
|
||||
}
|
||||
return job.ID, nil
|
||||
}
|
||||
|
||||
// ExistsSourceURLs 批量判断 source_url 是否已爬取过(爬虫抓取前的预检)。
|
||||
//
|
||||
// 返回命中集合:key 为已存在的 source_url,value 恒为 true。爬虫拿到结果后应跳过这些图集,
|
||||
// 不必再抓详情页、提取图片 URL 并上报——这一整套动作在判重命中时本来也会被 worker 丢弃。
|
||||
// 判定条件与 worker 判重完全一致(正式表未删除 / 草稿表 pending),因此不会漏抓也不会重复抓。
|
||||
func (s *IngestService) ExistsSourceURLs(ctx context.Context, urls []string) (map[string]bool, error) {
|
||||
return s.repo.ExistsSourceURLs(ctx, urls)
|
||||
}
|
||||
|
||||
// ListJobs 列出最近的入库任务(后台监控页用)。
|
||||
func (s *IngestService) ListJobs(ctx context.Context, limit int) ([]model.IngestJob, error) {
|
||||
return s.repo.ListJobs(ctx, limit)
|
||||
// ListJobs 分页列出入库任务(后台监控页用)。返回本页任务、总条数与错误。
|
||||
func (s *IngestService) ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, int64, error) {
|
||||
jobs, err := s.repo.ListJobs(ctx, offset, limit)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total, err := s.repo.CountJobs(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return jobs, total, nil
|
||||
}
|
||||
|
||||
// RetryJob 把一条 failed 任务重新入队,等待 worker 重新处理。
|
||||
@ -225,24 +221,43 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
return
|
||||
}
|
||||
|
||||
// 2) 按 source_url 去重(幂等):每来源一条草稿。多来源爬同品牌同季时各建一条草稿,
|
||||
// 晋升阶段(SaveRunwayFromDraft)按实体键(品牌+季节码+系列)合并为正式表一条并聚合图片。
|
||||
if _, found, err := s.repo.RunwayIDBySourceURL(ctx, p.SourceURL); err == nil && found {
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
return
|
||||
}
|
||||
if _, found, err := s.repo.DraftIDBySourceURL(ctx, p.SourceURL); err == nil && found {
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
return
|
||||
}
|
||||
|
||||
// 3) 补 season_code(vogue.go 历史漏填的 bug,统一在此兜底)
|
||||
// 2) 按实体键(brand_id + season_code + collection_type)去重:正式表已存在该秀则跳过,
|
||||
// 避免重爬重复下载。注意:只判正式表、不判 pending 草稿——否则多来源(Vogue + theImpression)
|
||||
// 爬同一场秀时第二个来源会被误判重复而丢弃,破坏晋升阶段的图片聚合。
|
||||
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
|
||||
if _, found, err := s.repo.RunwayIDByEntity(ctx, brandID, seasonCode, p.CollectionType); err == nil && found {
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
return
|
||||
}
|
||||
|
||||
// 4) 下载图片并上传到存储(七牛优先,失败兜底本地)
|
||||
cover, imgs, keys, imgFailed := s.fetchImages(ctx, p.Images, "runway")
|
||||
if len(p.Images) > 0 && imgFailed {
|
||||
// 单图失败=整任务失败:先回滚本批已上传的图(七牛 + 本地兜底),避免孤儿文件永远堆在存储里,
|
||||
// 3) season_code 已在去重前补齐(vogue.go 历史漏填的 bug,统一在此兜底)
|
||||
|
||||
// 4) 下载图片并上传到存储(结构化 Looks 优先:主图+细节图分组;否则回退 Images 全部视为主图)。
|
||||
// 内容哈希(sha1)key 保证重爬不产生孤儿文件:失败回滚删本批 key 即可。
|
||||
var cover string
|
||||
var draftImages []model.BrandRunwayDraftImage
|
||||
var keys []string
|
||||
var imgFailed bool
|
||||
var imageCount uint16
|
||||
if len(p.Looks) > 0 {
|
||||
cover, draftImages, keys, imgFailed = s.fetchLookImages(ctx, p.Looks, "runway")
|
||||
imageCount = uint16(len(p.Looks))
|
||||
} else {
|
||||
var imgs []string
|
||||
cover, imgs, keys, imgFailed = s.fetchImages(ctx, p.Images, "runway")
|
||||
imageCount = uint16(len(imgs))
|
||||
for i, img := range imgs {
|
||||
draftImages = append(draftImages, model.BrandRunwayDraftImage{
|
||||
Image: img,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
LookIndex: uint32(i + 1),
|
||||
IsDetail: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
if imgFailed {
|
||||
// 单图失败=整任务失败:先回滚本批已上传的图(S4 + 本地兜底),避免孤儿文件永远堆在存储里,
|
||||
// 然后按指数退避自动重试,达上限才置 failed 等后台手动重试。
|
||||
s.cleanupUploads(ctx, keys)
|
||||
s.failOrRetry(ctx, job.ID, "image download failed")
|
||||
@ -262,8 +277,7 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
CollectionType: p.CollectionType,
|
||||
SeasonCode: seasonCode,
|
||||
Cover: cover,
|
||||
SourceURL: p.SourceURL,
|
||||
ImageCount: uint16(len(imgs)),
|
||||
ImageCount: imageCount,
|
||||
Status: model.DraftStatusPending,
|
||||
}
|
||||
id, err := s.repo.CreateRunwayDraft(ctx, draft)
|
||||
@ -271,31 +285,75 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
s.failOrRetry(ctx, job.ID, "create draft: "+err.Error())
|
||||
return
|
||||
}
|
||||
rows := make([]model.BrandRunwayDraftImage, 0, len(imgs))
|
||||
for i, img := range imgs {
|
||||
rows = append(rows, model.BrandRunwayDraftImage{
|
||||
DraftID: id,
|
||||
Image: img,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
})
|
||||
for i := range draftImages {
|
||||
draftImages[i].DraftID = id
|
||||
}
|
||||
if err := s.repo.CreateRunwayDraftImages(ctx, rows); err != nil {
|
||||
if err := s.repo.CreateRunwayDraftImages(ctx, draftImages); err != nil {
|
||||
s.failOrRetry(ctx, job.ID, "create draft images: "+err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
}
|
||||
|
||||
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落草稿的
|
||||
// BrandRunwayDraftImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把
|
||||
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
|
||||
// cover 取首个成功下载的主图;image_count(主图数)由调用方按 len(Looks) 计,不在此返回。
|
||||
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayDraftImage, []string, bool) {
|
||||
cover := ""
|
||||
rows := make([]model.BrandRunwayDraftImage, 0)
|
||||
keys := make([]string, 0)
|
||||
failed := false
|
||||
order := 0
|
||||
for li, look := range looks {
|
||||
lookIdx := li + 1
|
||||
if look.Main != "" {
|
||||
order++
|
||||
url, key, err := s.downloadOne(ctx, look.Main, order, prefix)
|
||||
if err != nil {
|
||||
failed = true
|
||||
} else {
|
||||
if cover == "" {
|
||||
cover = url
|
||||
}
|
||||
keys = append(keys, key)
|
||||
rows = append(rows, model.BrandRunwayDraftImage{
|
||||
Image: url,
|
||||
Name: fmt.Sprintf("Look %d", lookIdx),
|
||||
SortOrder: uint32(order),
|
||||
LookIndex: uint32(lookIdx),
|
||||
IsDetail: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
for di, d := range look.Details {
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
order++
|
||||
url, key, err := s.downloadOne(ctx, d, order, prefix)
|
||||
if err != nil {
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
rows = append(rows, model.BrandRunwayDraftImage{
|
||||
Image: url,
|
||||
Name: fmt.Sprintf("Look %d — Detail %d", lookIdx, di+1),
|
||||
SortOrder: uint32(order),
|
||||
LookIndex: uint32(lookIdx),
|
||||
IsDetail: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
return cover, rows, keys, failed
|
||||
}
|
||||
|
||||
// processStreet 街拍入库:去重 → 下载图 → 写 street_snap_draft(无品牌)。
|
||||
func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
|
||||
// 1) 按 source_url 去重(幂等):每来源一条草稿。多来源爬同城市同年时各建一条草稿,
|
||||
// 晋升阶段(SaveStreetSnapFromDraft)按实体键(城市+年份)合并为正式表一条并聚合图片。
|
||||
if _, found, err := s.repo.StreetSnapIDBySourceURL(ctx, p.SourceURL); err == nil && found {
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
return
|
||||
}
|
||||
if _, found, err := s.repo.DraftStreetIDBySourceURL(ctx, p.SourceURL); err == nil && found {
|
||||
// 1) 按实体键(city + year)去重:正式表已存在该街拍则跳过,避免重爬重复下载。
|
||||
// 只判正式表、不判 pending 草稿,保留多来源街拍图片在晋升阶段聚合。
|
||||
if _, found, err := s.repo.StreetSnapIDByEntity(ctx, p.City, p.Year); err == nil && found {
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
return
|
||||
}
|
||||
@ -317,7 +375,6 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
Year: p.Year,
|
||||
City: p.City,
|
||||
Cover: cover,
|
||||
SourceURL: p.SourceURL,
|
||||
ImageCount: uint16(len(imgs)),
|
||||
Status: model.DraftStatusPending,
|
||||
}
|
||||
|
||||
@ -21,7 +21,6 @@ type DraftCard struct {
|
||||
Cover string `json:"cover"`
|
||||
ImageCount uint16 `json:"image_count"`
|
||||
Status string `json:"status"`
|
||||
SourceURL string `json:"source_url"`
|
||||
CreatedAt uint32 `json:"created_at"`
|
||||
UpdatedAt uint32 `json:"updated_at"`
|
||||
}
|
||||
@ -59,7 +58,6 @@ type DraftDetailView struct {
|
||||
SeasonCode string `json:"season_code"`
|
||||
City string `json:"city"`
|
||||
Cover string `json:"cover"`
|
||||
SourceURL string `json:"source_url"`
|
||||
ImageCount uint16 `json:"image_count"`
|
||||
Status string `json:"status"`
|
||||
Images []DraftImageRef `json:"images"`
|
||||
@ -102,7 +100,6 @@ type ReviewService interface {
|
||||
UpdateDraft(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
|
||||
CountPending(ctx context.Context) (int64, error)
|
||||
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删七牛文件)。
|
||||
DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
|
||||
// KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。
|
||||
@ -219,18 +216,6 @@ func (s *reviewService) Reject(ctx context.Context, kind string, id uint32, revi
|
||||
return m.Reject(ctx, id, reviewer, reason)
|
||||
}
|
||||
|
||||
func (s *reviewService) CountPending(ctx context.Context) (int64, error) {
|
||||
var total int64
|
||||
for _, k := range s.order {
|
||||
n, err := s.modules[k].CountPending(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total += n
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *reviewService) KindTabs(ctx context.Context) []KindTab {
|
||||
tabs := []KindTab{{Kind: "", Label: "全部"}}
|
||||
var all int64
|
||||
@ -315,8 +300,7 @@ func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
||||
Season: d.Season,
|
||||
CollectionType: d.CollectionType,
|
||||
SeasonCode: d.SeasonCode,
|
||||
Cover: m.img.Compose(d.Cover, true), // 后台预览看高清
|
||||
SourceURL: d.SourceURL,
|
||||
Cover: m.img.Compose(d.Cover), // 后台预览看展示图
|
||||
ImageCount: d.ImageCount,
|
||||
Status: d.Status,
|
||||
Fields: []EditField{
|
||||
@ -331,7 +315,7 @@ func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
||||
},
|
||||
}
|
||||
for _, im := range imgs {
|
||||
v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image, true), Name: im.Name, SortOrder: im.SortOrder})
|
||||
v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder})
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@ -392,8 +376,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
||||
Title: d.Title,
|
||||
Year: d.Year,
|
||||
City: d.City,
|
||||
Cover: m.img.Compose(d.Cover, true), // 后台预览看高清
|
||||
SourceURL: d.SourceURL,
|
||||
Cover: m.img.Compose(d.Cover), // 后台预览看展示图
|
||||
ImageCount: d.ImageCount,
|
||||
Status: d.Status,
|
||||
Fields: []EditField{
|
||||
@ -403,7 +386,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
||||
},
|
||||
}
|
||||
for _, im := range imgs {
|
||||
v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image, true), Name: im.Name, SortOrder: im.SortOrder})
|
||||
v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder})
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@ -448,10 +431,9 @@ func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard
|
||||
ID: d.ID,
|
||||
Title: d.TitleEn,
|
||||
Subtitle: sub,
|
||||
Cover: m.img.Compose(d.Cover, true), // 后台列表封面看高清
|
||||
Cover: m.img.Compose(d.Cover), // 后台列表封面看展示图
|
||||
ImageCount: d.ImageCount,
|
||||
Status: d.Status,
|
||||
SourceURL: d.SourceURL,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
})
|
||||
@ -471,10 +453,9 @@ func (m *streetModule) toStreetCards(rows []model.StreetSnapDraft) []DraftCard {
|
||||
ID: d.ID,
|
||||
Title: d.Title,
|
||||
Subtitle: sub,
|
||||
Cover: m.img.Compose(d.Cover, true), // 后台列表封面看高清
|
||||
Cover: m.img.Compose(d.Cover), // 后台列表封面看展示图
|
||||
ImageCount: d.ImageCount,
|
||||
Status: d.Status,
|
||||
SourceURL: d.SourceURL,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
})
|
||||
|
||||
@ -17,7 +17,7 @@ import (
|
||||
// StreetSnapService 街拍业务接口。
|
||||
type StreetSnapService interface {
|
||||
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
||||
Detail(ctx context.Context, id string, hd bool) (*dto.PublicStreetSnapDetail, error)
|
||||
Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
|
||||
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
||||
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
|
||||
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
||||
@ -43,9 +43,9 @@ type streetSnapService struct {
|
||||
}
|
||||
|
||||
// NewStreetSnapService 创建街拍服务。
|
||||
// img 负责把库里存储的七牛 key 拼装为可访问 URL(按 tier 决定普通/高清);
|
||||
// media + deleter 用于删除图集时清理七牛孤儿文件(引用计数归零才真删);
|
||||
// cleaner 把「删七牛」动作异步入队(由入库 worker 处理),避免阻塞删除请求。
|
||||
// img 负责把库里存储的 S4 key 拼装为可访问 URL(统一走展示样式,VIP 与免费同质量);
|
||||
// media + deleter 用于删除图集时清理孤儿文件(引用计数归零才真删);
|
||||
// cleaner 把「删存储」动作异步入队(由入库 worker 处理),避免阻塞删除请求。
|
||||
func NewStreetSnapService(repo repository.StreetSnapRepository, media repository.MediaRepository, img *imgurl.Composer, deleter storage.Deleter, cleaner MediaCleanupEnqueuer) StreetSnapService {
|
||||
return &streetSnapService{repo: repo, media: media, img: img, deleter: deleter, cleaner: cleaner}
|
||||
}
|
||||
@ -69,7 +69,7 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
|
||||
list = append(list, dto.PublicStreetSnap{
|
||||
UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID),
|
||||
Title: r.Title,
|
||||
Cover: s.img.Compose(r.Cover, false), // 列表封面统一普通图
|
||||
Cover: s.img.ComposeThumb(r.Cover), // 列表封面走缩略图样式(小尺寸省流量)
|
||||
ImageCount: r.ImageCount,
|
||||
})
|
||||
}
|
||||
@ -93,8 +93,9 @@ func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicS
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 列表缩略图统一普通图;再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。
|
||||
imgs := s.toPublicSnapImages(raw, false)
|
||||
// 列表缩略图走缩略图样式(与卡片封面同样式,故去重比较仍成立);
|
||||
// 再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。
|
||||
imgs := s.toThumbSnapImages(raw)
|
||||
imgs = excludeCoverSnap(imgs, list[i].Cover)
|
||||
if len(imgs) > limit {
|
||||
imgs = imgs[:limit]
|
||||
@ -118,9 +119,8 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA
|
||||
return kept
|
||||
}
|
||||
|
||||
// Detail 查询街拍详情,含完整图片集。
|
||||
// hd=true 时返回高清原图(VIP),false 时所有图拼普通样式(免费/未登录)。
|
||||
func (s *streetSnapService) Detail(ctx context.Context, id string, hd bool) (*dto.PublicStreetSnapDetail, error) {
|
||||
// Detail 查询街拍详情,含完整图片集。图片统一走展示样式(VIP 与免费同质量)。
|
||||
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||
snap, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
@ -137,8 +137,8 @@ func (s *streetSnapService) Detail(ctx context.Context, id string, hd bool) (*dt
|
||||
return &dto.PublicStreetSnapDetail{
|
||||
UID: hashid.EncodeTyped(hashid.TypeSnap, snap.ID),
|
||||
Title: snap.Title,
|
||||
Cover: s.img.Compose(snap.Cover, hd),
|
||||
Images: s.toPublicSnapImages(imgs, hd),
|
||||
Cover: s.img.Compose(snap.Cover),
|
||||
Images: s.toPublicSnapImages(imgs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -154,7 +154,7 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
|
||||
list = append(list, dto.PublicStreetSnap{
|
||||
UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID),
|
||||
Title: r.Title,
|
||||
Cover: s.img.Compose(r.Cover, false), // 热门街拍封面统一普通图
|
||||
Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high)
|
||||
ImageCount: r.ImageCount,
|
||||
})
|
||||
}
|
||||
@ -163,8 +163,9 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
|
||||
|
||||
// toPublicSnapImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
|
||||
// ID 用 TypeSnapImage 前缀编码,与街拍 uid(s 前缀)区分开,供单图收藏定位。
|
||||
// hd=true 时返回高清原图(VIP),false 时拼普通样式(免费/未登录)。
|
||||
func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage, hd bool) []dto.PublicArticleImage {
|
||||
// Image 走展示样式(high);额外给出 Thumb(缩略图样式),供大图灯箱左栏缩略图条等
|
||||
// 小尺寸场景使用(列表用的 toThumbSnapImages 不填 Thumb,其 Image 本身就是缩略图)。
|
||||
func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
|
||||
if len(imgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
@ -172,7 +173,25 @@ func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage, hd
|
||||
for _, im := range imgs {
|
||||
out = append(out, dto.PublicArticleImage{
|
||||
ID: hashid.EncodeTyped(hashid.TypeSnapImage, im.ID),
|
||||
Image: s.img.Compose(im.Image, hd),
|
||||
Image: s.img.Compose(im.Image),
|
||||
Thumb: s.img.ComposeThumb(im.Image),
|
||||
Name: im.Name,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toThumbSnapImages 与 toPublicSnapImages 同构,但图片地址走「列表缩略图」样式(ComposeThumb),
|
||||
// 仅供列表卡片 / 图条等小尺寸场景使用;详情页与大图灯箱仍用 toPublicSnapImages(展示样式)。
|
||||
func (s *streetSnapService) toThumbSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
|
||||
if len(imgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]dto.PublicArticleImage, 0, len(imgs))
|
||||
for _, im := range imgs {
|
||||
out = append(out, dto.PublicArticleImage{
|
||||
ID: hashid.EncodeTyped(hashid.TypeSnapImage, im.ID),
|
||||
Image: s.img.ComposeThumb(im.Image),
|
||||
Name: im.Name,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user