package service import ( "context" "errors" "sort" "strconv" "fashionapi/internal/dto" "fashionapi/internal/model" "fashionapi/internal/pkg/imgurl" "fashionapi/internal/pkg/storage" "fashionapi/internal/repository" ) // RecordCard 审核列表统一卡片(所有模块共用),供后台列表页渲染。 type RecordCard 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"` CreatedAt uint32 `json:"created_at"` UpdatedAt uint32 `json:"updated_at"` } // RecordImageRef 记录图片引用(统一结构)。 // // IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用); // DetailCount 让模板不用自己数副图。 type RecordImageRef struct { ID uint32 `json:"id"` Image string `json:"image"` Name string `json:"name"` SortOrder uint32 `json:"sort_order"` IsDetail uint8 `json:"is_detail"` // 1=副图(挂在 ParentImageID 指向的主图下);0=主图 ParentImageID uint32 `json:"parent_image_id"` // 副图所属主图的行 id;主图为 0 DetailCount int `json:"detail_count"` // 仅主图有意义:名下(存活)副图数量 } // 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 } // RecordDetailView 审核详情统一视图(所有模块共用)。 type RecordDetailView 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"` ImageCount uint16 `json:"image_count"` Status string `json:"status"` Images []RecordImageRef `json:"images"` // Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。 Fields []EditField `json:"fields"` // Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。 // 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。 Groups []ImageGroup `json:"groups,omitempty"` } // ImageGroup 一张主图及其副图,供审核页按组折叠渲染。 type ImageGroup struct { Image RecordImageRef `json:"image"` // 主图 Details []RecordImageRef `json:"details"` // 该主图名下的副图(可为空) } // ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于该记录 / 自身不是主图 / 空集合。 // 这里是给用户看的文案,handler 直接渲染 err.Error()。 var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本草稿、未被删除,且自身是主图") // KindTab 审核列表页顶部「类型」标签;Pending 为该模块待审数(驱动大审核列表概览)。 type KindTab struct { Kind string `json:"kind"` // 空串表示「全部」 Label string `json:"label"` Pending int64 `json:"pending"` } // ReviewModule 一种待审记录模块(runway / street / 未来任意爬虫数据类型)的统一契约。 // // 新增一种爬虫数据类型需要审核时,只需: // 1. 建正式表 + 迁移(记录自带 status 列); // 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/CountByStatus; // 3. 在此文件实现本接口并注册到 NewReviewService。 // // 列表、详情、通过、驳回、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。 type ReviewModule interface { Kind() string Label() string ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error // Approve 把正式记录置为 published;返回记录主键。 // 单表发布模型:通过只是改状态,不再晋升重建(图片在入库时已落到正式表)。 Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) Reject(ctx context.Context, id uint32, reviewer, reason string) error CountPending(ctx context.Context) (int64, error) } // ReviewService 审核的业务逻辑:列出 / 查看 / 微调 / 通过(置 published)/ 驳回。 // 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。 type ReviewService interface { ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error) RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error) UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error) Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error // DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。 DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error // AttachImages 把若干张图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。 AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error // DetachImage 把副图拆出恢复为主图(街拍专用)。 DetachImage(ctx context.Context, kind string, recordID, 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 负责把库里存储的S4 key 拼装为高清 URL(后台审核预览始终看高清原图); // media + deleter 用于审核阶段剔除坏图时清理S4孤儿文件(引用计数归零才真删)。 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) ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error) { if kind == "all" { kind = "" } if kind != "" { m, ok := s.module(kind) if !ok { return []RecordCard{}, 0, nil } return m.ListRecords(ctx, status, page, size) } // 合并所有模块:各取较多行后在内存按创建时间倒序,再分页(后台量小,足够准确)。 var all []RecordCard var total int64 for _, k := range s.order { cards, t, err := s.modules[k].ListRecords(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) RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error) { m, ok := s.module(kind) if !ok { return nil, repository.ErrNotFound } return m.RecordDetail(ctx, id) } func (s *reviewService) UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error { m, ok := s.module(kind) if !ok { return repository.ErrNotFound } return m.UpdateRecord(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) 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 } // DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图)。 // 按 kind 委派到对应图片表的软删,并同步记录 image_count; // 若该 key 在全库已无其他引用(引用计数归零),则一并清理S4孤儿文件。 func (s *reviewService) DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error { if _, ok := s.module(kind); !ok { return repository.ErrNotFound } key, err := s.repo.GetImageKey(ctx, kind, recordID, imgID) if err != nil { return err } switch kind { case dto.IngestKindRunway: err = s.repo.SoftDeleteRunwayImage(ctx, recordID, imgID) case dto.IngestKindStreet: err = s.repo.SoftDeleteStreetImage(ctx, recordID, imgID) default: return repository.ErrNotFound } if err != nil { return err } if key != "" { purgeOrphanImages(ctx, s.media, s.deleter, []string{key}) } return nil } func (s *reviewService) AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error { if kind != dto.IngestKindStreet { return ErrInvalidImageGroup } if len(imgIDs) == 0 { return ErrInvalidImageGroup } return translateGroupErr(s.repo.AttachStreetSnapImages(ctx, recordID, mainImgID, imgIDs)) } func (s *reviewService) DetachImage(ctx context.Context, kind string, recordID, imgID uint32) error { if kind != dto.IngestKindStreet { return ErrInvalidImageGroup } return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID)) } // translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。 func translateGroupErr(err error) error { if err == nil { return nil } if errors.Is(err, repository.ErrInvalidImageGroup) { return ErrInvalidImageGroup } return err } // ── 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) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) { rows, total, err := m.repo.ListRecords(ctx, status, page, size) if err != nil { return nil, 0, err } return m.toRunwayCards(rows), total, nil } func (m *runwayModule) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) { d, err := m.repo.RecordDetail(ctx, id) if err != nil { return nil, err } imgs, err := m.repo.RecordImages(ctx, id) if err != nil { return nil, err } v := &RecordDetailView{ 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), // 后台预览看展示图 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, RecordImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder}) } return v, nil } func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error { return m.repo.UpdateRecord(ctx, id, fields) } func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) { if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil { return 0, err } return id, nil } func (m *runwayModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error { return m.repo.SetRecordStatus(ctx, id, model.StatusRejected, reviewer, reason) } func (m *runwayModule) CountPending(ctx context.Context) (int64, error) { return m.repo.CountByStatus(ctx, model.StatusPending, &model.BrandRunway{}) } // ── 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) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) { rows, total, err := m.repo.ListStreetRecords(ctx, status, page, size) if err != nil { return nil, 0, err } return m.toStreetCards(rows), total, nil } func (m *streetModule) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) { d, err := m.repo.StreetRecordDetail(ctx, id) if err != nil { return nil, err } imgs, err := m.repo.StreetRecordImages(ctx, id) if err != nil { return nil, err } v := &RecordDetailView{ Kind: dto.IngestKindStreet, ID: d.ID, Title: d.Title, Year: d.Year, City: d.City, Cover: m.img.Compose(d.Cover), // 后台预览看展示图 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"}, }, } // 按 sort_order 顺序成组:主图各成一组;副图归入其(存活)主图组,父引用失效则自成一主图组。 // 父引用指向「副图」时沿链向上归并到该副图所在组的主图(保证与公开 API 同源同序)。 byID := make(map[uint32]model.StreetSnapImage, len(imgs)) for _, im := range imgs { byID[im.ID] = im } resolveRoot := func(start uint32) uint32 { cur := start seen := map[uint32]bool{} for { n, ok := byID[cur] if !ok { return start // 父行不存在:按主图渲染(返回自身) } if n.IsDetail == 0 { return n.ID } if seen[cur] { return start // 环:容错,自成主图 } seen[cur] = true cur = n.ParentImageID } } detailCount := make(map[uint32]int, len(imgs)) for _, im := range imgs { if im.IsDetail == 1 { if root := resolveRoot(im.ID); root != im.ID { detailCount[root]++ } } } groups := make([]ImageGroup, 0, len(imgs)) groupIndex := make(map[uint32]int, len(imgs)) for _, im := range imgs { root := resolveRoot(im.ID) isDetail := root != im.ID isDetailVal := uint8(0) parent := uint32(0) // 副图徽标显示「所属主图」:取沿链归并后的根主图;主图恒为 0 if isDetail { isDetailVal = 1 parent = root } ref := RecordImageRef{ ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder, IsDetail: isDetailVal, ParentImageID: parent, DetailCount: detailCount[im.ID], } v.Images = append(v.Images, ref) if !isDetail { groups = append(groups, ImageGroup{Image: ref}) groupIndex[im.ID] = len(groups) - 1 continue } if gi, ok := groupIndex[root]; ok { groups[gi].Details = append(groups[gi].Details, ref) } else { // 兜底(理论不发生):主图未先出现,自成一组 groups = append(groups, ImageGroup{Image: ref}) groupIndex[im.ID] = len(groups) - 1 } } v.Groups = groups return v, nil } func (m *streetModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error { return m.repo.UpdateStreetRecord(ctx, id, fields) } func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) { if err := m.repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil { return 0, err } return id, nil } func (m *streetModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error { return m.repo.SetStreetRecordStatus(ctx, id, model.StatusRejected, reviewer, reason) } func (m *streetModule) CountPending(ctx context.Context) (int64, error) { return m.repo.CountByStatus(ctx, model.StatusPending, &model.StreetSnap{}) } // ── 卡片转换(保留原逻辑)────────────────────────────────────────────────────── func (m *runwayModule) toRunwayCards(rows []model.BrandRunway) []RecordCard { cards := make([]RecordCard, 0, len(rows)) for _, d := range rows { sub := d.SeasonCode if d.CollectionType != "" { sub += " · " + d.CollectionType } if sub == "" { sub = "—" } cards = append(cards, RecordCard{ Kind: dto.IngestKindRunway, ID: d.ID, Title: d.TitleEn, Subtitle: sub, Cover: m.img.Compose(d.Cover), // 后台列表封面看展示图 ImageCount: d.ImageCount, Status: d.Status, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, }) } return cards } func (m *streetModule) toStreetCards(rows []model.StreetSnap) []RecordCard { cards := make([]RecordCard, 0, len(rows)) for _, d := range rows { sub := d.City if sub == "" { sub = "—" } cards = append(cards, RecordCard{ Kind: dto.IngestKindStreet, ID: d.ID, Title: d.Title, Subtitle: sub, Cover: m.img.Compose(d.Cover), // 后台列表封面看展示图 ImageCount: d.ImageCount, Status: d.Status, 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) }