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) }