update
This commit is contained in:
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