Files
backend_v2/internal/service/review_service.go
2026-09-20 01:06:13 +08:00

473 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 软删审核草稿里的单张图片(审核阶段剔除坏图,不删S4文件)。
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 负责把库里存储的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) 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 在全库已无其他引用(引用计数归零),则一并清理S4孤儿文件。
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)
}