update
This commit is contained in:
53
internal/service/ingest_cleanup_test.go
Normal file
53
internal/service/ingest_cleanup_test.go
Normal file
@ -0,0 +1,53 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/pkg/storage"
|
||||
)
|
||||
|
||||
// TestFetchImagesCleansUpOnFailure 验证「单图失败=整任务失败」时,本批已成功上传的对象被回滚删除,
|
||||
// 不会在存储里留下孤儿文件。
|
||||
func TestFetchImagesCleansUpOnFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
local := storage.NewLocalUploader(dir, "/uploads")
|
||||
s := &IngestService{uploader: local, local: local, httpClient: &http.Client{}}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "bad") {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("fakeimagebytes"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
urls := []string{srv.URL + "/ok1.jpg", srv.URL + "/ok2.jpg", srv.URL + "/bad.jpg"}
|
||||
_, out, keys, failed := s.fetchImages(context.Background(), urls, "runway")
|
||||
if !failed {
|
||||
t.Fatalf("expected failed=true when one image errors")
|
||||
}
|
||||
if len(out) != 2 || len(keys) != 2 {
|
||||
t.Fatalf("expected 2 successful uploads, got out=%d keys=%d", len(out), len(keys))
|
||||
}
|
||||
for _, k := range keys {
|
||||
if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(k))); err != nil {
|
||||
t.Fatalf("uploaded file missing before cleanup: %s", k)
|
||||
}
|
||||
}
|
||||
|
||||
s.cleanupUploads(context.Background(), keys)
|
||||
|
||||
for _, k := range keys {
|
||||
if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(k))); !os.IsNotExist(err) {
|
||||
t.Fatalf("orphan file not cleaned up: %s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
425
internal/service/ingest_service.go
Normal file
425
internal/service/ingest_service.go
Normal file
@ -0,0 +1,425 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/hashid"
|
||||
"fashionapi/internal/pkg/season"
|
||||
"fashionapi/internal/pkg/storage"
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
|
||||
// IngestService 爬虫入库管线的业务逻辑层:Submit 入队,worker 异步处理。
|
||||
//
|
||||
// 队列复用一张 ingest_jobs 表,靠 kind 列分流两类任务:
|
||||
// - crawl:爬虫上报的走秀/街拍入库(下载图 → 写草稿)
|
||||
// - media_cleanup:删除图集时异步清理七牛孤儿图(引用计数归零才真删)
|
||||
type IngestService struct {
|
||||
repo repository.IngestRepository
|
||||
brandRepo repository.BrandRepository
|
||||
media repository.MediaRepository // 跨表图片引用计数(media_cleanup 删孤儿用)
|
||||
uploader storage.Uploader // 主上传器(七牛启用时为七牛,否则本地)
|
||||
del storage.Deleter // 删除器(七牛或本地,media_cleanup 真删用)
|
||||
local *storage.LocalUploader // 七牛失败时的兜底落地
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewIngestService 创建入库服务。
|
||||
//
|
||||
// uploader: 主上传器(七牛或本地)
|
||||
// del: 删除器(七牛或本地),用于 media_cleanup 真删七牛/本地孤儿文件
|
||||
// local: 本地兜底上传器(七牛上传失败时回退,避免图片完全丢失)
|
||||
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader) *IngestService {
|
||||
return &IngestService{
|
||||
repo: repo,
|
||||
brandRepo: brandRepo,
|
||||
media: media,
|
||||
uploader: uploader,
|
||||
del: del,
|
||||
local: local,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Submit 把一条上报写入队列(payload 原样存 JSON),立即返回任务 id。
|
||||
// 真正处理由 worker 异步完成,因此本方法本身很快、不阻塞爬虫。
|
||||
func (s *IngestService) Submit(ctx context.Context, payload dto.RunwayIngest) (uint32, error) {
|
||||
if payload.Kind == "" {
|
||||
payload.Kind = dto.IngestKindRunway
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
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}
|
||||
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)
|
||||
}
|
||||
|
||||
// RetryJob 把一条 failed 任务重新入队,等待 worker 重新处理。
|
||||
func (s *IngestService) RetryJob(ctx context.Context, id uint32) error {
|
||||
return s.repo.RetryJob(ctx, id)
|
||||
}
|
||||
|
||||
// Run 启动 worker 循环:定时领取 pending 任务并处理,直到 ctx 取消。
|
||||
// 多实例安全:Claim 用 SKIP LOCKED,互不抢同一条。
|
||||
func (s *IngestService) Run(ctx context.Context, batch int, interval time.Duration) {
|
||||
if batch < 1 {
|
||||
batch = 2
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = 3 * time.Second
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.drain(ctx, batch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drain 一次性领取并处理一批任务。
|
||||
func (s *IngestService) drain(ctx context.Context, batch int) {
|
||||
jobs, err := s.repo.Claim(ctx, batch)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for i := range jobs {
|
||||
s.process(ctx, jobs[i])
|
||||
}
|
||||
}
|
||||
|
||||
// process 处理单条任务:先按 job.Kind(DB 列)分流到 crawl / media_cleanup 两条管线。
|
||||
// 旧爬虫任务(迁移前)kind 为空,回落 crawl 走秀/街拍入库管线。
|
||||
func (s *IngestService) process(ctx context.Context, job model.IngestJob) {
|
||||
switch job.Kind {
|
||||
case model.IngestKindMediaCleanup:
|
||||
s.processMediaCleanup(ctx, job)
|
||||
return
|
||||
case "", model.IngestKindCrawl:
|
||||
// 走秀/街拍入库管线,按 payload 内 Kind 再分流 runway / street。
|
||||
default:
|
||||
// 未知 job.Kind 绝不静默处理,标记失败避免脏数据。
|
||||
_ = s.repo.MarkFailed(ctx, job.ID, "unknown job kind: "+job.Kind)
|
||||
return
|
||||
}
|
||||
|
||||
var p dto.RunwayIngest
|
||||
if err := json.Unmarshal([]byte(job.Payload), &p); err != nil {
|
||||
_ = s.repo.MarkFailed(ctx, job.ID, "payload parse: "+err.Error())
|
||||
return
|
||||
}
|
||||
if p.Kind == "" {
|
||||
p.Kind = dto.IngestKindRunway
|
||||
}
|
||||
switch p.Kind {
|
||||
case dto.IngestKindRunway:
|
||||
s.processRunway(ctx, job, p)
|
||||
case dto.IngestKindStreet:
|
||||
s.processStreet(ctx, job, p)
|
||||
default:
|
||||
// 未知 kind 绝不静默当成走秀处理;所有爬虫数据都必须落到已注册的审核模块,
|
||||
// 否则标记任务失败,避免出现「未审核就入库」的脏数据。
|
||||
_ = s.repo.MarkFailed(ctx, job.ID, "unknown ingest kind: "+p.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// MediaCleanupPayload 是「清理七牛孤儿图」任务的 payload:待清理的图片 key 列表。
|
||||
type MediaCleanupPayload struct {
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
// EnqueueMediaCleanup 把一批待清理的七牛 key 异步入队;真正删除由 worker 的
|
||||
// processMediaCleanup 按引用计数判定,仅当 key 在所有图集/草稿表中引用归零才真删
|
||||
// (内容寻址共享 key 不会被误删)。
|
||||
func (s *IngestService) EnqueueMediaCleanup(ctx context.Context, keys []string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(MediaCleanupPayload{Keys: keys})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.EnqueueMediaCleanup(ctx, string(raw))
|
||||
}
|
||||
|
||||
// processMediaCleanup 清理七牛孤儿图任务:解析 key 列表,按跨表引用计数删除真孤儿。
|
||||
// 删除/计数失败一律保守跳过(宁可留文件),因此本任务几乎总是成功置 done。
|
||||
func (s *IngestService) processMediaCleanup(ctx context.Context, job model.IngestJob) {
|
||||
if s.media == nil || s.del == nil {
|
||||
_ = s.repo.MarkFailed(ctx, job.ID, "media cleanup not configured")
|
||||
return
|
||||
}
|
||||
var p MediaCleanupPayload
|
||||
if err := json.Unmarshal([]byte(job.Payload), &p); err != nil {
|
||||
_ = s.repo.MarkFailed(ctx, job.ID, "media cleanup payload parse: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(p.Keys) == 0 {
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
return
|
||||
}
|
||||
purgeOrphanImages(ctx, s.media, s.del, p.Keys)
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
}
|
||||
|
||||
// failOrRetry 把临时失败(如网络抖动 / 单图下载失败)按指数退避自动重试:
|
||||
// attempts 未达上限则重置为 pending 并推迟 next_attempt_at,worker 到点再领;
|
||||
// 达上限才置 failed,等待后台手动「重试」。永久失败(payload 解析错 / 未知 kind)直接
|
||||
// MarkFailed,不进重试。
|
||||
func (s *IngestService) failOrRetry(ctx context.Context, id uint32, errMsg string) {
|
||||
if err := s.repo.ScheduleRetry(ctx, id, errMsg); err != nil {
|
||||
// 调度失败兜底硬失败,避免任务卡在 processing 无人收。
|
||||
_ = s.repo.MarkFailed(ctx, id, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// processRunway 走秀入库:品牌校验 → 去重 → 补季节码 → 下载图 → 写 brand_runway_draft。
|
||||
func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
|
||||
// 1) 品牌必须存在(爬虫负责先建/复用品牌)
|
||||
brandID, err := hashid.Decode(p.BrandUID)
|
||||
if err != nil {
|
||||
_ = s.repo.MarkFailed(ctx, job.ID, "unknown brand_uid")
|
||||
return
|
||||
}
|
||||
if _, err := s.brandRepo.FindByID(ctx, brandID); err != nil {
|
||||
_ = s.repo.MarkFailed(ctx, job.ID, "brand not found")
|
||||
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,统一在此兜底)
|
||||
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
|
||||
|
||||
// 4) 下载图片并上传到存储(七牛优先,失败兜底本地)
|
||||
cover, imgs, keys, imgFailed := s.fetchImages(ctx, p.Images, "runway")
|
||||
if len(p.Images) > 0 && imgFailed {
|
||||
// 单图失败=整任务失败:先回滚本批已上传的图(七牛 + 本地兜底),避免孤儿文件永远堆在存储里,
|
||||
// 然后按指数退避自动重试,达上限才置 failed 等后台手动重试。
|
||||
s.cleanupUploads(ctx, keys)
|
||||
s.failOrRetry(ctx, job.ID, "image download failed")
|
||||
return
|
||||
}
|
||||
|
||||
// 5) 写草稿表(status=pending),等待后台审核通过后再晋升正式表
|
||||
draft := &model.BrandRunwayDraft{
|
||||
JobID: job.ID,
|
||||
BrandID: brandID,
|
||||
TitleEn: p.TitleEn,
|
||||
TitleCn: p.TitleCn,
|
||||
DescriptionEn: p.DescriptionEn,
|
||||
DescriptionCn: p.DescriptionCn,
|
||||
Year: p.Year,
|
||||
Season: p.Season,
|
||||
CollectionType: p.CollectionType,
|
||||
SeasonCode: seasonCode,
|
||||
Cover: cover,
|
||||
SourceURL: p.SourceURL,
|
||||
ImageCount: uint16(len(imgs)),
|
||||
Status: model.DraftStatusPending,
|
||||
}
|
||||
id, err := s.repo.CreateRunwayDraft(ctx, draft)
|
||||
if err != nil {
|
||||
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),
|
||||
})
|
||||
}
|
||||
if err := s.repo.CreateRunwayDraftImages(ctx, rows); err != nil {
|
||||
s.failOrRetry(ctx, job.ID, "create draft images: "+err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
return
|
||||
}
|
||||
|
||||
// 2) 下载图片并上传到存储(七牛优先,失败兜底本地)
|
||||
cover, imgs, keys, imgFailed := s.fetchImages(ctx, p.Images, "street")
|
||||
if len(p.Images) > 0 && imgFailed {
|
||||
// 单图失败=整任务失败:先回滚本批已上传的图(七牛 + 本地兜底),避免孤儿文件永远堆在存储里,
|
||||
// 然后按指数退避自动重试,达上限才置 failed 等后台手动重试。
|
||||
s.cleanupUploads(ctx, keys)
|
||||
s.failOrRetry(ctx, job.ID, "image download failed")
|
||||
return
|
||||
}
|
||||
|
||||
// 3) 写草稿表(status=pending),等待后台审核通过后再晋升 street_snap 正式表
|
||||
draft := &model.StreetSnapDraft{
|
||||
JobID: job.ID,
|
||||
Title: p.TitleEn, // 街拍单标题,爬虫优先填 title_en
|
||||
Year: p.Year,
|
||||
City: p.City,
|
||||
Cover: cover,
|
||||
SourceURL: p.SourceURL,
|
||||
ImageCount: uint16(len(imgs)),
|
||||
Status: model.DraftStatusPending,
|
||||
}
|
||||
id, err := s.repo.CreateStreetSnapDraft(ctx, draft)
|
||||
if err != nil {
|
||||
s.failOrRetry(ctx, job.ID, "create street draft: "+err.Error())
|
||||
return
|
||||
}
|
||||
rows := make([]model.StreetSnapDraftImage, 0, len(imgs))
|
||||
for i, img := range imgs {
|
||||
rows = append(rows, model.StreetSnapDraftImage{
|
||||
DraftID: id,
|
||||
Image: img,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
})
|
||||
}
|
||||
if err := s.repo.CreateStreetSnapDraftImages(ctx, rows); err != nil {
|
||||
s.failOrRetry(ctx, job.ID, "create street draft images: "+err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
}
|
||||
|
||||
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 全部图片地址, 已成功上传对象的 key 列表, 是否有任意一张失败)。
|
||||
// prefix 为七牛 key 前缀(runway/ 或 street/)。只要任意一张下载/上传失败,failed 即置 true,
|
||||
// 调用方据此把整条任务判为失败(不再写草稿),并拿 keys 回滚本批已上传的对象,符合「单图失败=整任务失败」策略。
|
||||
func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix string) (string, []string, []string, bool) {
|
||||
cover := ""
|
||||
out := make([]string, 0, len(urls))
|
||||
keys := make([]string, 0, len(urls))
|
||||
failed := false
|
||||
for i, u := range urls {
|
||||
url, key, err := s.downloadOne(ctx, u, i, prefix)
|
||||
if err != nil {
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
if i == 0 {
|
||||
cover = url
|
||||
}
|
||||
out = append(out, url)
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return cover, out, keys, failed
|
||||
}
|
||||
|
||||
// cleanupUploads 删除一批本批次成功上传的对象(七牛 + 本地兜底),用于任务失败回滚:
|
||||
// 「单图失败=整任务失败」时,前面已成功上传的图若放任不管就成了孤儿,永远堆在存储里。
|
||||
// 对两种存储都尝试删除(任一不存在即按幂等成功处理);删除失败仅告警,不阻断任务置失败。
|
||||
// 注意:key 为 sha1 内容寻址,若恰与其他已晋升图集共享同一内容哈希会被一并移除,重试会重新上传补齐。
|
||||
func (s *IngestService) cleanupUploads(ctx context.Context, keys []string) {
|
||||
for _, k := range keys {
|
||||
if d, ok := s.uploader.(storage.Deleter); ok {
|
||||
if err := d.Delete(ctx, k); err != nil {
|
||||
log.Printf("[warn] ingest cleanup: 七牛删除失败 key=%s err=%v", k, err)
|
||||
}
|
||||
}
|
||||
if s.local != nil {
|
||||
if err := s.local.Delete(ctx, k); err != nil {
|
||||
log.Printf("[warn] ingest cleanup: 本地删除失败 key=%s err=%v", k, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// downloadOne 把单张远程图下载后上传到存储(七牛优先,失败兜底本地),
|
||||
// 返回可直接写入数据库的访问地址(七牛为完整 https URL,本地为相对 /uploads 路径)。
|
||||
func (s *IngestService) downloadOne(ctx context.Context, u string, idx int, prefix string) (string, string, error) {
|
||||
resp, err := s.httpClient.Get(u)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
ext := path.Ext(u)
|
||||
if ext == "" || len(ext) > 5 {
|
||||
ext = ".jpg"
|
||||
}
|
||||
// 内容哈希作 key:同一张图(无论来自哪篇文章/job)永远得到相同 key,
|
||||
// 七牛覆盖写即天然幂等,不会因重复采集、崩溃重试、reject 重爬而累积孤儿文件。
|
||||
h := sha1.Sum(data)
|
||||
hash := hex.EncodeToString(h[:])
|
||||
key := fmt.Sprintf("%s/%s%s", prefix, hash, ext)
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
|
||||
// 主上传器(七牛)
|
||||
if url, err := s.uploader.Upload(ctx, key, data, ct); err == nil {
|
||||
return url, key, nil
|
||||
} else if s.local != nil {
|
||||
// 兜底本地,避免图片完全丢失
|
||||
if lurl, lerr := s.local.Upload(ctx, key, data, ct); lerr == nil {
|
||||
return lurl, key, nil
|
||||
} else {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
86
internal/service/ingest_service_test.go
Normal file
86
internal/service/ingest_service_test.go
Normal file
@ -0,0 +1,86 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/storage"
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
|
||||
// fakeMediaRepo 计数固定返回 0:即所有 key 都是孤儿,应被真删(不误删的前提是调用方已软删引用)。
|
||||
type fakeMediaRepo struct{ repository.MediaRepository }
|
||||
|
||||
func (fakeMediaRepo) CountImageKeyRefs(ctx context.Context, key string) (int, error) { return 0, nil }
|
||||
|
||||
// fakeIngestRepo 仅实现 processMediaCleanup 需要的 MarkDone/MarkFailed(其余由内嵌 nil 接口兜底,不会被调用)。
|
||||
type fakeIngestRepo struct{ repository.IngestRepository }
|
||||
|
||||
func (fakeIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil }
|
||||
func (fakeIngestRepo) MarkFailed(ctx context.Context, id uint32, errMsg string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// countingDeleter 包装 LocalUploader,记录被删除的 key,便于断言。
|
||||
type countingDeleter struct {
|
||||
*storage.LocalUploader
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (c *countingDeleter) Delete(ctx context.Context, key string) error {
|
||||
c.deleted = append(c.deleted, key)
|
||||
return c.LocalUploader.Delete(ctx, key)
|
||||
}
|
||||
|
||||
// TestProcessMediaCleanup 验证「清理七牛孤儿图」任务:引用计数为 0 的 key 会被真删。
|
||||
func TestProcessMediaCleanup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
up := storage.NewLocalUploader(dir, "/uploads")
|
||||
del := &countingDeleter{LocalUploader: up}
|
||||
|
||||
// 预置一个"孤儿"文件,确认它确实存在。
|
||||
if _, err := up.Upload(context.Background(), "runway/abc.jpg", []byte("x"), "image/jpeg"); err != nil {
|
||||
t.Fatalf("预置文件失败: %v", err)
|
||||
}
|
||||
|
||||
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up)
|
||||
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/abc.jpg"]}`}
|
||||
svc.processMediaCleanup(context.Background(), job)
|
||||
|
||||
if len(del.deleted) != 1 || del.deleted[0] != "runway/abc.jpg" {
|
||||
t.Fatalf("期望删除 1 个孤儿 key,实际 %v", del.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessMediaCleanupSkipsReferenced 引用计数 > 0 的 key 必须保留(不误删共享 key)。
|
||||
func TestProcessMediaCleanupSkipsReferenced(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
up := storage.NewLocalUploader(dir, "/uploads")
|
||||
del := &countingDeleter{LocalUploader: up}
|
||||
|
||||
// 引用计数恒为 1(仍被其他图集使用),不应真删。
|
||||
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up)
|
||||
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/shared.jpg"]}`}
|
||||
svc.processMediaCleanup(context.Background(), job)
|
||||
|
||||
if len(del.deleted) != 0 {
|
||||
t.Fatalf("被引用的 key 不应被删除,实际 %v", del.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
type referencedMediaRepo struct{ repository.MediaRepository }
|
||||
|
||||
func (referencedMediaRepo) CountImageKeyRefs(ctx context.Context, key string) (int, error) {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// TestIngestRetryBackoff 验证指数退避:30s / 60s / 120s 且封顶 600s。
|
||||
func TestIngestRetryBackoff(t *testing.T) {
|
||||
cases := map[int]int{1: 30, 2: 60, 3: 120, 10: 600}
|
||||
for attempts, want := range cases {
|
||||
if got := model.IngestRetryBackoff(attempts); got != want {
|
||||
t.Fatalf("IngestRetryBackoff(%d)=%d, want %d", attempts, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
45
internal/service/media_purge.go
Normal file
45
internal/service/media_purge.go
Normal file
@ -0,0 +1,45 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"fashionapi/internal/pkg/storage"
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
|
||||
// purgeOrphanImages 清理七牛/本地孤儿图片:仅当某 key 在所有图集/草稿表中
|
||||
// 引用计数归零时才真正删除对象;否则保留(仍有其他图集在用,避免误删)。
|
||||
//
|
||||
// 设计要点(为何必须引用计数而非「删库即删七牛」):
|
||||
// 七牛 key 为 sha1 内容寻址,同一张图跨图集共享同一 key。直接删会误伤其他图集。
|
||||
// 另外,删除失败/计数失败一律「保守跳过」——宁可留文件,不冒误删被引用文件的风险。
|
||||
//
|
||||
// 参数 del 为 storage.Deleter(七牛或本地实现),由装配点按配置注入。
|
||||
func purgeOrphanImages(ctx context.Context, media repository.MediaRepository, del storage.Deleter, keys []string) {
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
for _, k := range keys {
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[k]; ok {
|
||||
continue // 同一次批量去重,避免重复删
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
|
||||
n, err := media.CountImageKeyRefs(ctx, k)
|
||||
if err != nil {
|
||||
log.Printf("[purge] 引用计数失败,跳过 key=%s: %v", k, err)
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
// 仍被引用(含其他图集/草稿),保留。
|
||||
continue
|
||||
}
|
||||
if err := del.Delete(ctx, k); err != nil {
|
||||
log.Printf("[purge] 七牛/本地删除失败,保留 key=%s: %v", k, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[purge] 已清理孤儿图片 key=%s", k)
|
||||
}
|
||||
}
|
||||
491
internal/service/review_service.go
Normal file
491
internal/service/review_service.go
Normal file
@ -0,0 +1,491 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
"fashionapi/internal/pkg/imgurl"
|
||||
"fashionapi/internal/pkg/storage"
|
||||
"fashionapi/internal/repository"
|
||||
)
|
||||
|
||||
// DraftCard 审核列表统一卡片(所有模块共用),供后台列表页渲染。
|
||||
type DraftCard struct {
|
||||
Kind string `json:"kind"` // runway | street | 未来其它模块
|
||||
ID uint32 `json:"id"`
|
||||
Title string `json:"title"` // 展示标题
|
||||
Subtitle string `json:"subtitle"` // 副标题(runway=季节码·类型,street=城市)
|
||||
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"`
|
||||
}
|
||||
|
||||
// DraftImageRef 草稿图片引用(统一结构)。
|
||||
type DraftImageRef struct {
|
||||
ID uint32 `json:"id"`
|
||||
Image string `json:"image"`
|
||||
Name string `json:"name"`
|
||||
SortOrder uint32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
// EditField 审核详情页里可编辑字段的通用描述;详情页按此循环渲染输入框,
|
||||
// 新增模块只需声明自己的字段集合,无需再改模板分支。
|
||||
type EditField struct {
|
||||
Name string `json:"name"` // 表单字段名,如 title_en
|
||||
Label string `json:"label"` // 中文标签,如 英文标题
|
||||
Value string `json:"value"` // 当前值
|
||||
Type string `json:"type"` // text | textarea | number
|
||||
}
|
||||
|
||||
// DraftDetailView 审核详情统一视图(所有模块共用)。
|
||||
type DraftDetailView struct {
|
||||
Kind string `json:"kind"`
|
||||
ID uint32 `json:"id"`
|
||||
BrandID uint32 `json:"brand_id"` // 仅 runway 等有品牌的模块有,其余为 0
|
||||
Title string `json:"title"` // 展示标题(runway=title_en,street=title)
|
||||
TitleEn string `json:"title_en"`
|
||||
TitleCn string `json:"title_cn"`
|
||||
DescriptionEn string `json:"description_en"`
|
||||
DescriptionCn string `json:"description_cn"`
|
||||
Year uint16 `json:"year"`
|
||||
Season string `json:"season"`
|
||||
CollectionType string `json:"collection_type"`
|
||||
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"`
|
||||
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
|
||||
Fields []EditField `json:"fields"`
|
||||
}
|
||||
|
||||
// KindTab 审核列表页顶部「类型」标签;Pending 为该模块待审数(驱动大审核列表概览)。
|
||||
type KindTab struct {
|
||||
Kind string `json:"kind"` // 空串表示「全部」
|
||||
Label string `json:"label"`
|
||||
Pending int64 `json:"pending"`
|
||||
}
|
||||
|
||||
// ReviewModule 一种待审草稿模块(runway / street / 未来任意爬虫数据类型)的统一契约。
|
||||
//
|
||||
// 新增一种爬虫数据类型需要审核时,只需:
|
||||
// 1. 建草稿表 + 迁移;
|
||||
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/SaveFromDraft/CountPending;
|
||||
// 3. 在此文件实现本接口并注册到 NewReviewService。
|
||||
//
|
||||
// 列表、详情、通过、驳回、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
|
||||
type ReviewModule interface {
|
||||
Kind() string
|
||||
Label() string
|
||||
ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error)
|
||||
DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error)
|
||||
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// Approve 晋升到正式表并把草稿置 approved;返回正式表主键。
|
||||
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 审核草稿的业务逻辑:列出 / 查看 / 微调 / 通过(晋升)/ 驳回。
|
||||
// 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。
|
||||
type ReviewService interface {
|
||||
ListDrafts(ctx context.Context, kind, status string, page, size int) ([]DraftCard, int64, error)
|
||||
DraftDetail(ctx context.Context, kind string, id uint32) (*DraftDetailView, error)
|
||||
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 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。
|
||||
KindTabs(ctx context.Context) []KindTab
|
||||
}
|
||||
|
||||
type reviewService struct {
|
||||
repo repository.ReviewRepository
|
||||
media repository.MediaRepository
|
||||
img *imgurl.Composer
|
||||
deleter storage.Deleter
|
||||
modules map[string]ReviewModule
|
||||
order []string // 保持稳定顺序:注册顺序
|
||||
}
|
||||
|
||||
// NewReviewService 创建审核服务并注册所有已知模块。
|
||||
// img 负责把库里存储的七牛 key 拼装为高清 URL(后台审核预览始终看高清原图);
|
||||
// media + deleter 用于审核阶段剔除坏图时清理七牛孤儿文件(引用计数归零才真删)。
|
||||
func NewReviewService(repo repository.ReviewRepository, media repository.MediaRepository, img *imgurl.Composer, deleter storage.Deleter) ReviewService {
|
||||
svc := &reviewService{
|
||||
repo: repo,
|
||||
media: media,
|
||||
img: img,
|
||||
deleter: deleter,
|
||||
modules: map[string]ReviewModule{},
|
||||
}
|
||||
for _, m := range []ReviewModule{
|
||||
&runwayModule{repo: repo, img: img},
|
||||
&streetModule{repo: repo, img: img},
|
||||
} {
|
||||
svc.modules[m.Kind()] = m
|
||||
svc.order = append(svc.order, m.Kind())
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
func (s *reviewService) module(kind string) (ReviewModule, bool) {
|
||||
m, ok := s.modules[kind]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
func (s *reviewService) ListDrafts(ctx context.Context, kind, status string, page, size int) ([]DraftCard, int64, error) {
|
||||
if kind == "all" {
|
||||
kind = ""
|
||||
}
|
||||
if kind != "" {
|
||||
m, ok := s.module(kind)
|
||||
if !ok {
|
||||
return []DraftCard{}, 0, nil
|
||||
}
|
||||
return m.ListDrafts(ctx, status, page, size)
|
||||
}
|
||||
// 合并所有模块:各取较多行后在内存按创建时间倒序,再分页(后台量小,足够准确)。
|
||||
var all []DraftCard
|
||||
var total int64
|
||||
for _, k := range s.order {
|
||||
cards, t, err := s.modules[k].ListDrafts(ctx, status, 1, 1000)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
all = append(all, cards...)
|
||||
total += t
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].CreatedAt > all[j].CreatedAt })
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
start := (page - 1) * size
|
||||
if start < 0 || start > len(all) {
|
||||
start = 0
|
||||
}
|
||||
end := start + size
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
if start > end {
|
||||
start = end
|
||||
}
|
||||
return all[start:end], total, nil
|
||||
}
|
||||
|
||||
func (s *reviewService) DraftDetail(ctx context.Context, kind string, id uint32) (*DraftDetailView, error) {
|
||||
m, ok := s.module(kind)
|
||||
if !ok {
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
return m.DraftDetail(ctx, id)
|
||||
}
|
||||
|
||||
func (s *reviewService) UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error {
|
||||
m, ok := s.module(kind)
|
||||
if !ok {
|
||||
return repository.ErrNotFound
|
||||
}
|
||||
return m.UpdateDraft(ctx, id, fields)
|
||||
}
|
||||
|
||||
func (s *reviewService) Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error) {
|
||||
m, ok := s.module(kind)
|
||||
if !ok {
|
||||
return 0, repository.ErrNotFound
|
||||
}
|
||||
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) 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
|
||||
for _, k := range s.order {
|
||||
m := s.modules[k]
|
||||
n, err := m.CountPending(ctx)
|
||||
if err != nil {
|
||||
n = 0
|
||||
}
|
||||
tabs = append(tabs, KindTab{Kind: k, Label: m.Label(), Pending: n})
|
||||
all += n
|
||||
}
|
||||
tabs[0].Pending = all
|
||||
return tabs
|
||||
}
|
||||
|
||||
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图)。
|
||||
// 按 kind 委派到对应草稿图片表的软删,并同步草稿 image_count;
|
||||
// 若该 key 在全库已无其他引用(引用计数归零),则一并清理七牛孤儿文件。
|
||||
func (s *reviewService) DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error {
|
||||
if _, ok := s.module(kind); !ok {
|
||||
return repository.ErrNotFound
|
||||
}
|
||||
key, err := s.repo.GetDraftImageKey(ctx, kind, draftID, imgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch kind {
|
||||
case dto.IngestKindRunway:
|
||||
err = s.repo.SoftDeleteRunwayDraftImage(ctx, draftID, imgID)
|
||||
case dto.IngestKindStreet:
|
||||
err = s.repo.SoftDeleteStreetDraftImage(ctx, draftID, imgID)
|
||||
default:
|
||||
return repository.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if key != "" {
|
||||
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── runway 模块 ────────────────────────────────────────────────────────────────
|
||||
|
||||
type runwayModule struct {
|
||||
repo repository.ReviewRepository
|
||||
img *imgurl.Composer
|
||||
}
|
||||
|
||||
func (m *runwayModule) Kind() string { return dto.IngestKindRunway }
|
||||
func (m *runwayModule) Label() string { return "走秀" }
|
||||
|
||||
func (m *runwayModule) ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) {
|
||||
rows, total, err := m.repo.ListDrafts(ctx, status, page, size)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return m.toRunwayCards(rows), total, nil
|
||||
}
|
||||
|
||||
func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) {
|
||||
d, err := m.repo.DraftDetail(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imgs, err := m.repo.DraftImages(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := &DraftDetailView{
|
||||
Kind: dto.IngestKindRunway,
|
||||
ID: d.ID,
|
||||
BrandID: d.BrandID,
|
||||
Title: d.TitleEn,
|
||||
TitleEn: d.TitleEn,
|
||||
TitleCn: d.TitleCn,
|
||||
DescriptionEn: d.DescriptionEn,
|
||||
DescriptionCn: d.DescriptionCn,
|
||||
Year: d.Year,
|
||||
Season: d.Season,
|
||||
CollectionType: d.CollectionType,
|
||||
SeasonCode: d.SeasonCode,
|
||||
Cover: m.img.Compose(d.Cover, true), // 后台预览看高清
|
||||
SourceURL: d.SourceURL,
|
||||
ImageCount: d.ImageCount,
|
||||
Status: d.Status,
|
||||
Fields: []EditField{
|
||||
{Name: "title_en", Label: "英文标题", Value: d.TitleEn, Type: "text"},
|
||||
{Name: "title_cn", Label: "中文标题", Value: d.TitleCn, Type: "text"},
|
||||
{Name: "description_en", Label: "英文描述", Value: d.DescriptionEn, Type: "textarea"},
|
||||
{Name: "description_cn", Label: "中文描述", Value: d.DescriptionCn, Type: "textarea"},
|
||||
{Name: "year", Label: "年份", Value: yearStr(d.Year), Type: "number"},
|
||||
{Name: "season", Label: "季节", Value: d.Season, Type: "text"},
|
||||
{Name: "collection_type", Label: "系列", Value: d.CollectionType, Type: "text"},
|
||||
{Name: "season_code", Label: "季节码", Value: d.SeasonCode, Type: "text"},
|
||||
},
|
||||
}
|
||||
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})
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (m *runwayModule) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error {
|
||||
return m.repo.UpdateDraft(ctx, id, fields)
|
||||
}
|
||||
|
||||
func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
|
||||
officialID, err := m.repo.SaveRunwayFromDraft(ctx, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := m.repo.SetDraftStatus(ctx, id, model.DraftStatusApproved, reviewer, ""); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return officialID, nil
|
||||
}
|
||||
|
||||
func (m *runwayModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
|
||||
return m.repo.SetDraftStatus(ctx, id, model.DraftStatusRejected, reviewer, reason)
|
||||
}
|
||||
|
||||
func (m *runwayModule) CountPending(ctx context.Context) (int64, error) {
|
||||
return m.repo.CountDraftPending(ctx, model.DraftStatusPending, &model.BrandRunwayDraft{})
|
||||
}
|
||||
|
||||
// ── street 模块 ────────────────────────────────────────────────────────────────
|
||||
|
||||
type streetModule struct {
|
||||
repo repository.ReviewRepository
|
||||
img *imgurl.Composer
|
||||
}
|
||||
|
||||
func (m *streetModule) Kind() string { return dto.IngestKindStreet }
|
||||
func (m *streetModule) Label() string { return "街拍" }
|
||||
|
||||
func (m *streetModule) ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) {
|
||||
rows, total, err := m.repo.ListStreetDrafts(ctx, status, page, size)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return m.toStreetCards(rows), total, nil
|
||||
}
|
||||
|
||||
func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) {
|
||||
d, err := m.repo.StreetDraftDetail(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imgs, err := m.repo.StreetDraftImages(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := &DraftDetailView{
|
||||
Kind: dto.IngestKindStreet,
|
||||
ID: d.ID,
|
||||
Title: d.Title,
|
||||
Year: d.Year,
|
||||
City: d.City,
|
||||
Cover: m.img.Compose(d.Cover, true), // 后台预览看高清
|
||||
SourceURL: d.SourceURL,
|
||||
ImageCount: d.ImageCount,
|
||||
Status: d.Status,
|
||||
Fields: []EditField{
|
||||
{Name: "title", Label: "标题", Value: d.Title, Type: "text"},
|
||||
{Name: "year", Label: "年份", Value: yearStr(d.Year), Type: "number"},
|
||||
{Name: "city", Label: "城市", Value: d.City, Type: "text"},
|
||||
},
|
||||
}
|
||||
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})
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (m *streetModule) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error {
|
||||
return m.repo.UpdateStreetDraft(ctx, id, fields)
|
||||
}
|
||||
|
||||
func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
|
||||
officialID, err := m.repo.SaveStreetSnapFromDraft(ctx, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := m.repo.SetStreetDraftStatus(ctx, id, model.DraftStatusApproved, reviewer, ""); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return officialID, nil
|
||||
}
|
||||
|
||||
func (m *streetModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
|
||||
return m.repo.SetStreetDraftStatus(ctx, id, model.DraftStatusRejected, reviewer, reason)
|
||||
}
|
||||
|
||||
func (m *streetModule) CountPending(ctx context.Context) (int64, error) {
|
||||
return m.repo.CountDraftPending(ctx, model.DraftStatusPending, &model.StreetSnapDraft{})
|
||||
}
|
||||
|
||||
// ── 卡片转换(保留原逻辑)──────────────────────────────────────────────────────
|
||||
|
||||
func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard {
|
||||
cards := make([]DraftCard, 0, len(rows))
|
||||
for _, d := range rows {
|
||||
sub := d.SeasonCode
|
||||
if d.CollectionType != "" {
|
||||
sub += " · " + d.CollectionType
|
||||
}
|
||||
if sub == "" {
|
||||
sub = "—"
|
||||
}
|
||||
cards = append(cards, DraftCard{
|
||||
Kind: dto.IngestKindRunway,
|
||||
ID: d.ID,
|
||||
Title: d.TitleEn,
|
||||
Subtitle: sub,
|
||||
Cover: m.img.Compose(d.Cover, true), // 后台列表封面看高清
|
||||
ImageCount: d.ImageCount,
|
||||
Status: d.Status,
|
||||
SourceURL: d.SourceURL,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
func (m *streetModule) toStreetCards(rows []model.StreetSnapDraft) []DraftCard {
|
||||
cards := make([]DraftCard, 0, len(rows))
|
||||
for _, d := range rows {
|
||||
sub := d.City
|
||||
if sub == "" {
|
||||
sub = "—"
|
||||
}
|
||||
cards = append(cards, DraftCard{
|
||||
Kind: dto.IngestKindStreet,
|
||||
ID: d.ID,
|
||||
Title: d.Title,
|
||||
Subtitle: sub,
|
||||
Cover: m.img.Compose(d.Cover, true), // 后台列表封面看高清
|
||||
ImageCount: d.ImageCount,
|
||||
Status: d.Status,
|
||||
SourceURL: d.SourceURL,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
// yearStr 把 uint16 年份转字符串(0 显示为空)。
|
||||
func yearStr(y uint16) string {
|
||||
if y == 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatUint(uint64(y), 10)
|
||||
}
|
||||
Reference in New Issue
Block a user