473 lines
16 KiB
Go
473 lines
16 KiB
Go
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"`
|
||
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"`
|
||
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
|
||
// 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) 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), // 后台预览看展示图
|
||
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), 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), // 后台预览看展示图
|
||
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), 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), // 后台列表封面看展示图
|
||
ImageCount: d.ImageCount,
|
||
Status: d.Status,
|
||
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), // 后台列表封面看展示图
|
||
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)
|
||
}
|