This commit is contained in:
toom1996
2026-09-07 00:04:01 +08:00
parent 6a5a4378ab
commit 10d8a96e8c
29 changed files with 5349 additions and 0 deletions

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