625 lines
22 KiB
Go
625 lines
22 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"sort"
|
||
"strconv"
|
||
|
||
"fashionapi/internal/dto"
|
||
"fashionapi/internal/model"
|
||
"fashionapi/internal/pkg/imgurl"
|
||
"fashionapi/internal/pkg/storage"
|
||
"fashionapi/internal/repository"
|
||
)
|
||
|
||
// RecordCard 审核列表统一卡片(所有模块共用),供后台列表页渲染。
|
||
type RecordCard 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"`
|
||
}
|
||
|
||
// RecordImageRef 记录图片引用(统一结构)。
|
||
//
|
||
// 街拍 / 走秀共用同一套主副图模型:is_detail=0 为主图(parent_image_id=0),
|
||
// =1 为副图(parent_image_id 指向同记录内主图的行 id)。DetailCount 让模板不用自己数副图。
|
||
type RecordImageRef 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
|
||
}
|
||
|
||
// RecordDetailView 审核详情统一视图(所有模块共用)。
|
||
type RecordDetailView 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 []RecordImageRef `json:"images"`
|
||
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
|
||
Fields []EditField `json:"fields"`
|
||
// Groups 详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。
|
||
// 街拍与走秀均按 parent_image_id 分组(见各模块 RecordDetail),结构同构。
|
||
// 与 Images 同源同序:Images 保持扁平(既有渲染),Groups 供按组折叠。
|
||
Groups []ImageGroup `json:"groups,omitempty"`
|
||
}
|
||
|
||
// ImageGroup 一张主图及其副图,供审核页按组折叠渲染。
|
||
type ImageGroup struct {
|
||
Image RecordImageRef `json:"image"` // 主图
|
||
Details []RecordImageRef `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. 建正式表 + 迁移(记录自带 status 列);
|
||
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/CountByStatus;
|
||
// 3. 在此文件实现本接口并注册到 NewReviewService。
|
||
//
|
||
// 列表、详情、通过、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
|
||
type ReviewModule interface {
|
||
Kind() string
|
||
Label() string
|
||
ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error)
|
||
RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error)
|
||
UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
|
||
// Approve 把正式记录置为 published;返回记录主键。
|
||
// 单表发布模型:通过只是改状态,不再晋升重建(图片在入库时已落到正式表)。
|
||
Approve(ctx context.Context, id uint32, reviewer string) (uint32, error)
|
||
CountPending(ctx context.Context) (int64, error)
|
||
}
|
||
|
||
// ReviewService 审核的业务逻辑:列出 / 查看 / 微调 / 通过(置 published)。
|
||
// 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。
|
||
//
|
||
// 注意:审核不再提供「驳回」动作。不想发布的待审项直接走「下架 / 删除」
|
||
// (articleService / streetSnapService 的 SetDeleted → PurgeGallery),
|
||
// 会级联软删图片并异步入队清理 S4 孤儿图(按引用计数判定),而非停留在 rejected 态。
|
||
type ReviewService interface {
|
||
ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error)
|
||
RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error)
|
||
UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error
|
||
Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error)
|
||
// DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
|
||
DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error
|
||
// AttachImages 把若干张图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
|
||
AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error
|
||
// DetachImage 把副图拆出恢复为主图(街拍专用)。
|
||
DetachImage(ctx context.Context, kind string, recordID, 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) ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error) {
|
||
if kind == "all" {
|
||
kind = ""
|
||
}
|
||
if kind != "" {
|
||
m, ok := s.module(kind)
|
||
if !ok {
|
||
return []RecordCard{}, 0, nil
|
||
}
|
||
return m.ListRecords(ctx, status, page, size)
|
||
}
|
||
// 合并所有模块:各取较多行后在内存按创建时间倒序,再分页(后台量小,足够准确)。
|
||
var all []RecordCard
|
||
var total int64
|
||
for _, k := range s.order {
|
||
cards, t, err := s.modules[k].ListRecords(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) RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error) {
|
||
m, ok := s.module(kind)
|
||
if !ok {
|
||
return nil, repository.ErrNotFound
|
||
}
|
||
return m.RecordDetail(ctx, id)
|
||
}
|
||
|
||
func (s *reviewService) UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error {
|
||
m, ok := s.module(kind)
|
||
if !ok {
|
||
return repository.ErrNotFound
|
||
}
|
||
return m.UpdateRecord(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) 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
|
||
}
|
||
|
||
// DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图)。
|
||
// 按 kind 委派到对应图片表的软删,并同步记录 image_count;
|
||
// 若该 key 在全库已无其他引用(引用计数归零),则一并清理S4孤儿文件。
|
||
func (s *reviewService) DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error {
|
||
if _, ok := s.module(kind); !ok {
|
||
return repository.ErrNotFound
|
||
}
|
||
key, err := s.repo.GetImageKey(ctx, kind, recordID, imgID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
switch kind {
|
||
case dto.IngestKindRunway:
|
||
err = s.repo.SoftDeleteRunwayImage(ctx, recordID, imgID)
|
||
case dto.IngestKindStreet:
|
||
err = s.repo.SoftDeleteStreetImage(ctx, recordID, 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) AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error {
|
||
if len(imgIDs) == 0 {
|
||
return ErrInvalidImageGroup
|
||
}
|
||
switch kind {
|
||
case dto.IngestKindStreet:
|
||
return translateGroupErr(s.repo.AttachStreetSnapImages(ctx, recordID, mainImgID, imgIDs))
|
||
case dto.IngestKindRunway:
|
||
return translateGroupErr(s.repo.AttachRunwayImages(ctx, recordID, mainImgID, imgIDs))
|
||
default:
|
||
return ErrInvalidImageGroup
|
||
}
|
||
}
|
||
|
||
func (s *reviewService) DetachImage(ctx context.Context, kind string, recordID, imgID uint32) error {
|
||
switch kind {
|
||
case dto.IngestKindStreet:
|
||
return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID))
|
||
case dto.IngestKindRunway:
|
||
return translateGroupErr(s.repo.DetachRunwayImage(ctx, recordID, imgID))
|
||
default:
|
||
return ErrInvalidImageGroup
|
||
}
|
||
}
|
||
|
||
// 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) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) {
|
||
rows, total, err := m.repo.ListRecords(ctx, status, page, size)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return m.toRunwayCards(rows), total, nil
|
||
}
|
||
|
||
func (m *runwayModule) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) {
|
||
d, err := m.repo.RecordDetail(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
imgs, err := m.repo.RecordImages(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
v := &RecordDetailView{
|
||
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"},
|
||
},
|
||
}
|
||
// 按 parent_image_id 成组(与街拍同构):is_detail=0 为主图;is_detail=1 且 parent_image_id>0
|
||
// 的是其副图;is_detail=1 且 parent_image_id=0 的孤儿细节图按主图渲染(自成一组,不丢图)。
|
||
// 这样走秀详情页(消费 Groups)与街拍同构,编辑页(消费 Images)也能按主图→细节图顺序渲染。
|
||
mains := make([]model.BrandRunwayImage, 0, len(imgs))
|
||
mainSeen := make(map[uint32]bool, len(imgs))
|
||
for _, im := range imgs {
|
||
isMain := im.IsDetail == 0 || im.ParentImageID == 0
|
||
if isMain && !mainSeen[im.ID] {
|
||
mainSeen[im.ID] = true
|
||
mains = append(mains, im)
|
||
}
|
||
}
|
||
detailsByParent := make(map[uint32][]model.BrandRunwayImage, len(imgs))
|
||
for _, im := range imgs {
|
||
if im.IsDetail == 1 && im.ParentImageID != 0 && !mainSeen[im.ID] {
|
||
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
|
||
}
|
||
}
|
||
detailCount := make(map[uint32]int, len(detailsByParent))
|
||
for p, ds := range detailsByParent {
|
||
detailCount[p] = len(ds)
|
||
}
|
||
for _, main := range mains {
|
||
ref := RecordImageRef{
|
||
ID: main.ID,
|
||
Image: m.img.Compose(main.Image),
|
||
Name: main.Name,
|
||
SortOrder: main.SortOrder,
|
||
IsDetail: 0,
|
||
ParentImageID: 0,
|
||
DetailCount: detailCount[main.ID],
|
||
}
|
||
v.Images = append(v.Images, ref)
|
||
grp := ImageGroup{Image: ref}
|
||
for _, d := range detailsByParent[main.ID] {
|
||
dref := RecordImageRef{
|
||
ID: d.ID,
|
||
Image: m.img.Compose(d.Image),
|
||
Name: d.Name,
|
||
SortOrder: d.SortOrder,
|
||
IsDetail: 1,
|
||
ParentImageID: d.ParentImageID,
|
||
}
|
||
grp.Details = append(grp.Details, dref)
|
||
v.Images = append(v.Images, dref)
|
||
}
|
||
v.Groups = append(v.Groups, grp)
|
||
}
|
||
return v, nil
|
||
}
|
||
|
||
|
||
func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
|
||
return m.repo.UpdateRecord(ctx, id, fields)
|
||
}
|
||
|
||
func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
|
||
if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer); err != nil {
|
||
return 0, err
|
||
}
|
||
return id, nil
|
||
}
|
||
|
||
func (m *runwayModule) CountPending(ctx context.Context) (int64, error) {
|
||
return m.repo.CountByStatus(ctx, model.StatusPending, &model.BrandRunway{})
|
||
}
|
||
|
||
// ── 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) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) {
|
||
rows, total, err := m.repo.ListStreetRecords(ctx, status, page, size)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return m.toStreetCards(rows), total, nil
|
||
}
|
||
|
||
func (m *streetModule) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) {
|
||
d, err := m.repo.StreetRecordDetail(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
imgs, err := m.repo.StreetRecordImages(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
v := &RecordDetailView{
|
||
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.StreetSnapImage, 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([]ImageGroup, 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 := RecordImageRef{
|
||
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, ImageGroup{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, ImageGroup{Image: ref})
|
||
groupIndex[im.ID] = len(groups) - 1
|
||
}
|
||
}
|
||
v.Groups = groups
|
||
return v, nil
|
||
}
|
||
|
||
func (m *streetModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
|
||
return m.repo.UpdateStreetRecord(ctx, id, fields)
|
||
}
|
||
|
||
func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
|
||
if err := m.repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, reviewer); err != nil {
|
||
return 0, err
|
||
}
|
||
return id, nil
|
||
}
|
||
|
||
func (m *streetModule) CountPending(ctx context.Context) (int64, error) {
|
||
return m.repo.CountByStatus(ctx, model.StatusPending, &model.StreetSnap{})
|
||
}
|
||
|
||
// ── 卡片转换(保留原逻辑)──────────────────────────────────────────────────────
|
||
|
||
func (m *runwayModule) toRunwayCards(rows []model.BrandRunway) []RecordCard {
|
||
cards := make([]RecordCard, 0, len(rows))
|
||
for _, d := range rows {
|
||
sub := d.SeasonCode
|
||
if d.CollectionType != "" {
|
||
sub += " · " + d.CollectionType
|
||
}
|
||
if sub == "" {
|
||
sub = "—"
|
||
}
|
||
cards = append(cards, RecordCard{
|
||
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.StreetSnap) []RecordCard {
|
||
cards := make([]RecordCard, 0, len(rows))
|
||
for _, d := range rows {
|
||
sub := d.City
|
||
if sub == "" {
|
||
sub = "—"
|
||
}
|
||
cards = append(cards, RecordCard{
|
||
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)
|
||
}
|