Files
backend_v2/internal/service/review_service.go

591 lines
20 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"
"errors"
"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 草稿图片引用(统一结构)。
//
// IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用);
// DetailCount 让模板不用自己数副图。
type DraftImageRef 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
}
// 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"`
// Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。
// 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。
Groups []DraftImageGroup `json:"groups,omitempty"`
}
// DraftImageGroup 一张主图及其副图,供审核页按组折叠渲染。
type DraftImageGroup struct {
Image DraftImageRef `json:"image"` // 主图
Details []DraftImageRef `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. 建草稿表 + 迁移;
// 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
// AttachDraftImages 把若干张草稿图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
AttachDraftImages(ctx context.Context, kind string, draftID, mainImgID uint32, imgIDs []uint32) error
// DetachDraftImage 把副图拆出恢复为主图(街拍专用)。
DetachDraftImage(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
}
func (s *reviewService) AttachDraftImages(ctx context.Context, kind string, draftID, mainImgID uint32, imgIDs []uint32) error {
if kind != dto.IngestKindStreet {
return ErrInvalidImageGroup
}
if len(imgIDs) == 0 {
return ErrInvalidImageGroup
}
return translateGroupErr(s.repo.AttachStreetDraftImages(ctx, draftID, mainImgID, imgIDs))
}
func (s *reviewService) DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error {
if kind != dto.IngestKindStreet {
return ErrInvalidImageGroup
}
return translateGroupErr(s.repo.DetachStreetDraftImage(ctx, draftID, 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) 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"},
},
}
// 按 sort_order 顺序成组:主图各成一组;副图归入其(存活)主图组,父引用失效则自成一主图组。
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图(保证与公开 API 同源同序)。
byID := make(map[uint32]model.StreetSnapDraftImage, 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([]DraftImageGroup, 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 := DraftImageRef{
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, DraftImageGroup{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, DraftImageGroup{Image: ref})
groupIndex[im.ID] = len(groups) - 1
}
}
v.Groups = groups
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)
}