fix(publish): 后台详情与品牌列表改读基表,修复未发布内容在后台不可达

This commit is contained in:
toom1996
2026-09-23 10:16:09 +08:00
parent f5144038ab
commit 4169ebe1ed
9 changed files with 287 additions and 52 deletions

View File

@ -107,7 +107,10 @@ func (h *BackstageHandler) UserSetTier(c *gin.Context) {
c.Redirect(http.StatusFound, "/admin/users")
}
// Brands 品牌管理列表:支持关键词搜索、A-Z 字母索引、分页,复用 BrandService.List。
// Brands 品牌管理列表:支持关键词搜索、A-Z 字母索引、分页,复用 BrandService.AdminList。
//
// 必须用 AdminList:公开 List 的「必须有已发布走秀档案」子查询会把只有待审走秀的品牌挡掉,
// 后台需要仍能看到它们(否则待审品牌从后台列表消失)。
func (h *BackstageHandler) Brands(c *gin.Context) {
q := dto.BrandQuery{
Keyword: c.Query("keyword"),
@ -115,7 +118,7 @@ func (h *BackstageHandler) Brands(c *gin.Context) {
Page: atoiDefault(c.Query("page"), 1),
Locale: "en",
}
items, total, err := h.brand.List(c.Request.Context(), q)
items, total, err := h.brand.AdminList(c.Request.Context(), q)
if err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
@ -225,7 +228,10 @@ func (h *BackstageHandler) Runways(c *gin.Context) {
})
}
// RunwayDetail 走秀详情:展示完整图集,复用 ArticleService.Detail。
// RunwayDetail 走秀详情:展示完整图集,复用 ArticleService.DetailAdmin(读基表,含待审)。
//
// 必须用 DetailAdmin:后台列表刻意展示 pending / rejected 行并链到本页,
// 公开的 ArticleService.Detail 读视图会把它们当不存在,导致待审项 404。
// 路由 :id 是 runway 类型化编码(无前缀,类型进密码),按 TypeRunway 解码(与公开接口 article_handler 一致)。
func (h *BackstageHandler) RunwayDetail(c *gin.Context) {
raw := c.Param("id")
@ -234,7 +240,7 @@ func (h *BackstageHandler) RunwayDetail(c *gin.Context) {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw})
return
}
art, err := h.article.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), "en")
art, err := h.article.DetailAdmin(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), "en")
if err != nil {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw})
return
@ -278,7 +284,10 @@ func (h *BackstageHandler) StreetSnaps(c *gin.Context) {
})
}
// StreetSnapDetail 街拍详情:展示完整图集,复用 StreetSnapService.Detail。
// StreetSnapDetail 街拍详情:展示完整图集,复用 StreetSnapService.DetailAdmin(读基表,含待审)。
//
// 必须用 DetailAdmin:后台列表刻意展示 pending / rejected 行并链到本页,
// 公开的 StreetSnapService.Detail 读视图会把它们当不存在,导致待审项 404。
// 路由 :id 是 snap 类型化编码(无前缀,类型进密码),按 TypeSnap 解码(与公开接口 street_snap_handler 一致)。
func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
raw := c.Param("id")
@ -287,7 +296,7 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
return
}
snap, err := h.street.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
snap, err := h.street.DetailAdmin(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
if err != nil {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
return

View File

@ -18,10 +18,15 @@ type ArticleRepository interface {
List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
// ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code 等管理字段。
ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
// FindByID 查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
// FindByID 公开读:查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
FindByID(ctx context.Context, id string) (*model.RunwayRow, error)
// ListImages 查询某篇文章的全部图片,按排序值升序。
// FindByIDBase 后台读基表:与 FindByID 同投影/同 JOIN,但能看到 pending / rejected。
// 后台列表故意展示待审行并链到详情页,详情必须走基表,否则待审项点进去 404。
FindByIDBase(ctx context.Context, id string) (*model.RunwayRow, error)
// ListImages 公开读:查询某篇文章的全部图片,按排序值升序(未发布图片不可见)。
ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
// ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。
ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
// ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。
ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error)
// GetForEdit 取原始走秀记录(含全部 en/cn 字段)与图片,供后台编辑表单预填。
@ -196,11 +201,20 @@ func (r *articleRepository) ListAdmin(ctx context.Context, q dto.ArticleQuery) (
return rows, total, nil
}
// FindByID 公开读:读公开视图,pending / rejected 视同不存在(返回 ErrNotFound)。
func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) {
return r.findByID(r.db.WithContext(ctx).Table(publicRunways), id)
}
// FindByIDBase 后台读基表:同投影同 JOIN,但不过滤 status(后台详情需渲染待审内容)。
func (r *articleRepository) FindByIDBase(ctx context.Context, id string) (*model.RunwayRow, error) {
return r.findByID(r.db.WithContext(ctx).Model(&model.BrandRunway{}), id)
}
// findByID 用给定查询(视图或基表)取单行;投影与 JOIN 两处保持一致,避免公开/后台口径漂移。
func (r *articleRepository) findByID(q *gorm.DB, id string) (*model.RunwayRow, error) {
var row model.RunwayRow
err := r.db.WithContext(ctx).
Table(publicRunways).
Select(articleDetailColumns).
err := q.Select(articleDetailColumns).
Joins(brandJoin).
Where("brand_runways.id = ? AND brand_runways.is_deleted = 0", id).
First(&row).Error
@ -224,8 +238,8 @@ func (r *articleRepository) ListImages(ctx context.Context, runwayID string) ([]
return imgs, err
}
// listImagesBase 读基表(含未发布),仅供后台 GetForEdit 使用 —— 否则编辑待审内容时看不到图。
func (r *articleRepository) listImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
// ListImagesBase 读基表(含未发布),供后台 GetForEdit / 后台详情使用 —— 否则待审内容看不到图。
func (r *articleRepository) ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
var imgs []model.BrandRunwayImage
err := r.db.WithContext(ctx).
Where("runway_id = ? AND is_deleted = 0", runwayID).
@ -278,7 +292,7 @@ func (r *articleRepository) GetForEdit(ctx context.Context, id uint32) (*model.B
}
return nil, nil, err
}
imgs, err := r.listImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
imgs, err := r.ListImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
if err != nil {
return nil, nil, err
}

View File

@ -13,12 +13,16 @@ import (
// BrandRepository 品牌数据访问接口。
type BrandRepository interface {
// List 按字母索引 / 关键词分页查询品牌。
// List 按字母索引 / 关键词分页查询品牌(公开侧:字母索引只认已发布走秀档案)。
//
// restrictIDs 用于 featured 模式:非 nil 时结果被限制在这些品牌 id 内;
// 若 q.Featured 为 true 而 restrictIDs 为空,则返回空结果——绝不能退化成"全部品牌"。
List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error)
// ListAdmin 后台列表:与 List 同构,但字母索引不要求走秀已发布,
// 使只有待审 / 驳回走秀的品牌仍出现在后台(公开侧保护不受影响)。
ListAdmin(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error)
// FeaturedIDs 返回"代表品牌"的 id 集合:在拥有走秀档案的品牌中,
// 按 metric(images = 图片总数 / shows = 发布会数)排名取前 limit 个。
FeaturedIDs(ctx context.Context, metric string, limit int) ([]uint32, error)
@ -71,7 +75,7 @@ func NewBrandRepository(db *gorm.DB) BrandRepository {
// 排序与索引统一基于 name_en(拉丁规范名);name_en 为空的纯中文品牌归入 OTHER 桶。
const latinPrefixRegexp = "^[A-Za-z]"
// hasArticlesSubQuery 子查询:所有「至少有一篇已发布走秀档案」的品牌 id。
// hasArticlesSubQuery 子查询(公开侧):所有「至少有一篇已发布走秀档案」的品牌 id。
// 库中存在 2 万+ 无档案品牌,索引必须靠它收敛。
// 读公开视图而非基表:否则只有待审走秀的品牌会提前出现在前台的 A-Z 索引里。
func (r *brandRepository) hasArticlesSubQuery() *gorm.DB {
@ -79,8 +83,20 @@ func (r *brandRepository) hasArticlesSubQuery() *gorm.DB {
Select("DISTINCT brand_id")
}
// hasArticlesSubQueryBase 子查询(后台侧):所有「至少有一篇未删除走秀档案」的品牌 id。
// 读基表且不看 status:后台品牌列表必须仍能看到只有待审 / 驳回走秀的品牌,
// 否则它们会从后台「消失」,审核员无处下手(与公开侧的保护并不冲突:公开走视图)。
func (r *brandRepository) hasArticlesSubQueryBase() *gorm.DB {
return r.db.Model(&model.BrandRunway{}).
Select("DISTINCT brand_id").
Where("is_deleted = 0")
}
// brandFilterScope 把品牌查询条件编译为 GORM Scope。
func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint32) func(*gorm.DB) *gorm.DB {
//
// admin 为 true 时(后台列表)「必须有走秀档案」的子查询读基表且不过滤 status,
// 使只有待审 / 驳回走秀的品牌仍出现在后台;公开列表保持读视图(保护未发布内容)。
func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint32, admin bool) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
db = db.Where("is_deleted = 0")
@ -96,12 +112,19 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3
}
// 字母索引始终只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌,
// 不过滤会污染 A-Z 索引)。原 only_with_articles 参数已删除,此过滤无条件生效。
// 不过滤会污染 A-Z 索引)。原 only_with_articles 参数已删除,此过滤无条件生效;
// 唯一区别是"档案"的口径:公开=已发布(视图),后台=未删除(基表,含待审)。
// 例外:关键词搜索时不限制"必须有走秀档案"(用户按名字找品牌,
// 即使该品牌暂无走秀档案也应被命中,如 A Bathing Ape / 猿人头);
// 中文分桶(OTHER)同样列出全部中文名品牌便于检索。
if q.Keyword == "" && q.Letter != "OTHER" {
db = db.Where("id IN (?)", r.hasArticlesSubQuery())
if admin {
// 后台:只要求「存在未删除走秀档案」,待审 / 驳回的品牌也带出来。
db = db.Where("id IN (?)", r.hasArticlesSubQueryBase())
} else {
// 公开:只认已发布走秀(视图),未发布内容不得出现在前台 A-Z 索引。
db = db.Where("id IN (?)", r.hasArticlesSubQuery())
}
}
if q.Keyword != "" {
@ -121,8 +144,19 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3
}
}
// List 公开品牌列表:字母索引只保留「至少有一篇已发布走秀档案」的品牌。
func (r *brandRepository) List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) {
scope := r.brandFilterScope(q, restrictIDs)
return r.list(ctx, q, restrictIDs, false)
}
// ListAdmin 后台品牌列表:与 List 同构,但字母索引不要求走秀已发布,
// 只有待审 / 驳回走秀的品牌仍能看到(否则它们从后台列表消失,无处审核/编辑)。
func (r *brandRepository) ListAdmin(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) {
return r.list(ctx, q, restrictIDs, true)
}
func (r *brandRepository) list(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32, admin bool) ([]model.Brand, int64, error) {
scope := r.brandFilterScope(q, restrictIDs, admin)
var total int64
if err := r.db.WithContext(ctx).

View File

@ -51,6 +51,16 @@ func TestPublicReadHidesUnpublishedRunway(t *testing.T) {
t.Fatalf("后台应读到 pending 行与其图片,实际 rw=%v imgs=%d", rw != nil, len(baseImgs))
}
// 后台详情通路:FindByIDBase 也必须读得到 pending(后台详情页据此渲染;
// 若它读视图,后台列表里的待审行点进去就 404)。
baseRow, err := repo.FindByIDBase(ctx, idStr)
if err != nil {
t.Fatalf("后台 FindByIDBase 应读到 pending 行: %v", err)
}
if baseRow == nil || baseRow.ID != id {
t.Fatalf("后台 FindByIDBase 返回不符: %+v", baseRow)
}
// 置为已发布后,公开侧才可见。
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
t.Fatalf("置为已发布失败: %v", err)
@ -113,6 +123,15 @@ func TestPublicReadHidesUnpublishedStreet(t *testing.T) {
t.Fatalf("后台列表应看到 pending 街拍,实际 total=%d", adminTotal)
}
// 后台详情通路:FindByIDBase 也必须读得到 pending(否则后台详情页 404)。
baseSnap, err := repo.FindByIDBase(ctx, idStr)
if err != nil {
t.Fatalf("后台 FindByIDBase 应读到 pending 行: %v", err)
}
if baseSnap == nil || baseSnap.ID != id {
t.Fatalf("后台 FindByIDBase 返回不符: %+v", baseSnap)
}
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
t.Fatalf("置为已发布失败: %v", err)
}
@ -124,3 +143,59 @@ func TestPublicReadHidesUnpublishedStreet(t *testing.T) {
t.Fatalf("已发布街拍应出现在公开列表,实际 total=%d", total)
}
}
// hasBrandID 判断品牌列表里是否含指定 id(按 id 判定,避免受库里既有同名/同首字母品牌干扰)。
func hasBrandID(items []model.Brand, id uint32) bool {
for _, it := range items {
if it.ID == id {
return true
}
}
return false
}
// TestBrandIndexSplitsPublicAndAdmin 品牌 A-Z 索引的公开/后台分野:
// 只有待审走秀的品牌不得出现在公开索引(否则未发布内容从索引泄露),
// 但必须出现在后台列表(否则该品牌从后台「消失」,审核员无从下手)。
func TestBrandIndexSplitsPublicAndAdmin(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
ctx := context.Background()
repo := NewBrandRepository(db)
now := uint32(1_800_000_000)
brand := model.Brand{NameEn: "ViewTestBrand", NameCn: "视图测试品牌", CreatedAt: now, UpdatedAt: now}
if err := db.Create(&brand).Error; err != nil {
t.Fatalf("插入品牌失败: %v", err)
}
rw := model.BrandRunway{
BrandID: brand.ID, TitleEn: "view-test-brand", Year: 1995,
Status: model.StatusPending, CreatedAt: now, UpdatedAt: now,
}
if err := db.Create(&rw).Error; err != nil {
t.Fatalf("插入走秀失败: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM brand_runways WHERE id = ?", rw.ID)
db.Exec("DELETE FROM brands WHERE id = ?", brand.ID)
})
// 字母桶 V + 大页,确保命中「必须有走秀档案」的子查询分支、且不会被分页截断。
q := dto.BrandQuery{Letter: "V", Page: 1, Size: 400}
pubItems, _, err := repo.List(ctx, q, nil)
if err != nil {
t.Fatalf("公开 List 出错: %v", err)
}
if hasBrandID(pubItems, brand.ID) {
t.Fatalf("只有待审走秀的品牌不应出现在公开索引,实际出现在结果里(id=%d)", brand.ID)
}
admItems, _, err := repo.ListAdmin(ctx, q, nil)
if err != nil {
t.Fatalf("后台 ListAdmin 出错: %v", err)
}
if !hasBrandID(admItems, brand.ID) {
t.Fatalf("后台列表应看到只有待审走秀的品牌(id=%d)", brand.ID)
}
}

View File

@ -18,10 +18,15 @@ type StreetSnapRepository interface {
List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error)
// ListAdmin 后台管理列表:读基表(含 pending / rejected),与 article_repository.ListAdmin 对称。
ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error)
// FindByID 查询单条街拍;不存在时返回 ErrNotFound。
// FindByID 公开读:查询单条街拍;不存在时返回 ErrNotFound(pending / rejected 视为不存在)。
FindByID(ctx context.Context, id string) (*model.StreetSnap, error)
// ListImages 查询某条街拍的全部图片,按排序值升序。
// FindByIDBase 后台读基表:与 FindByID 同形,但能看到 pending / rejected。
// 后台列表故意展示待审行并链到详情页,详情必须走基表,否则待审项点进去 404。
FindByIDBase(ctx context.Context, id string) (*model.StreetSnap, error)
// ListImages 公开读:查询某条街拍的全部图片,按排序值升序(未发布图片不可见)。
ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error)
// ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。
ListImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error)
// ImagesBySnapIDs 批量查询多条街拍的图片,单次 IN 查询避免 N+1。
ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error)
// Popular 按图片数降序返回热度前 N 个街拍,供 SSG 首页热门街拍使用。
@ -136,11 +141,18 @@ func (r *streetSnapRepository) ListAdmin(ctx context.Context, q dto.StreetSnapQu
// FindByID 公开读:读公开视图,pending / rejected 街拍视同不存在(返回 ErrNotFound)。
func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.StreetSnap, error) {
return r.findByID(r.db.WithContext(ctx).Table(publicStreetSnaps), id)
}
// FindByIDBase 后台读基表:不过滤 status(后台详情需渲染待审内容)。
func (r *streetSnapRepository) FindByIDBase(ctx context.Context, id string) (*model.StreetSnap, error) {
return r.findByID(r.db.WithContext(ctx), id)
}
// findByID 用给定查询(视图或基表)取单行,公开/后台口径只在数据来源上不同。
func (r *streetSnapRepository) findByID(q *gorm.DB, id string) (*model.StreetSnap, error) {
var snap model.StreetSnap
err := r.db.WithContext(ctx).
Table(publicStreetSnaps).
Where("id = ? AND is_deleted = 0", id).
First(&snap).Error
err := q.Where("id = ? AND is_deleted = 0", id).First(&snap).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
@ -161,8 +173,8 @@ func (r *streetSnapRepository) ListImages(ctx context.Context, snapID string) ([
return imgs, err
}
// listImagesBase 读基表(含未发布),仅供后台 GetForEdit 使用 —— 否则编辑待审内容时看不到图。
func (r *streetSnapRepository) listImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) {
// ListImagesBase 读基表(含未发布),供后台 GetForEdit / 后台详情使用 —— 否则待审内容看不到图。
func (r *streetSnapRepository) ListImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) {
var imgs []model.StreetSnapImage
err := r.db.WithContext(ctx).
Where("snap_id = ? AND is_deleted = 0", snapID).
@ -226,7 +238,7 @@ func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*mode
}
return nil, nil, err
}
imgs, err := r.listImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
imgs, err := r.ListImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
if err != nil {
return nil, nil, err
}

View File

@ -50,6 +50,11 @@ type fakeBrand struct{}
func (f *fakeBrand) List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
return []dto.PublicBrand{{UID: "001DESke", NameEn: "Acne Studios", NameCn: "艾克妮"}}, 1, nil
}
// AdminList 后台品牌列表:假实现里与 List 同源(本假实现不区分走秀发布态)。
func (f *fakeBrand) AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
return f.List(ctx, q)
}
func (f *fakeBrand) Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error) {
return nil, nil
}
@ -104,11 +109,19 @@ func (f *fakeArticle) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.
ImageCount: 12,
}}, 1, nil
}
// Detail 公开详情。本 fake 代表一条「待审」内容:公开路径读视图,读不到,
// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。
func (f *fakeArticle) Detail(ctx context.Context, id, locale string) (*dto.PublicArticleDetail, error) {
return nil, service.ErrArticleNotFound
}
// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。
func (f *fakeArticle) DetailAdmin(ctx context.Context, id, locale string) (*dto.PublicArticleDetail, error) {
return &dto.PublicArticleDetail{
UID: id,
BrandUID: "001DESke",
Title: "Acne Studios Spring 2024",
Title: "Acne Studios Spring 2024(待审)",
BrandName: "Acne Studios",
Images: []dto.PublicArticleImage{
{ID: "i1", Image: "/uploads/runway/1.jpg", Name: "Look 1"},
@ -149,10 +162,17 @@ func (f *fakeStreet) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dt
return f.List(ctx, q)
}
// Detail 公开详情。本 fake 代表一条「待审」街拍:公开路径读视图,读不到,
// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。
func (f *fakeStreet) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
return nil, service.ErrStreetSnapNotFound
}
// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。
func (f *fakeStreet) DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
return &dto.PublicStreetSnapDetail{
UID: id,
Title: "Paris Fashion Week Street",
Title: "Paris Fashion Week Street(待审)",
Images: []dto.PublicArticleImage{
{ID: "j1", Image: "/uploads/snap/1.jpg", Name: "Snap 1"},
},
@ -270,13 +290,15 @@ func TestBackstage(t *testing.T) {
t.Fatalf("走秀列表异常: %d %s", w.Code, w.Body.String())
}
// 7) 走秀详情渲染图集
// 7) 后台走秀详情渲染图集。
// fakeArticle.Detail(公开路径)对这条「待审」内容固定返回 not found,
// 这里仍 200 且渲染出「待审」标记,即证明后台详情走的是 DetailAdmin(读基表)。
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/runways/r001DESke", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Look 1") {
t.Fatalf("走秀详情异常: %d %s", w.Code, w.Body.String())
if w.Code != 200 || !strings.Contains(w.Body.String(), "Look 1") || !strings.Contains(w.Body.String(), "(待审)") {
t.Fatalf("后台应能渲染待审走秀详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
}
// 8) 街拍列表渲染
@ -288,13 +310,14 @@ func TestBackstage(t *testing.T) {
t.Fatalf("街拍列表异常: %d %s", w.Code, w.Body.String())
}
// 9) 街拍详情渲染图集
// 9) 后台街拍详情渲染图集。
// 同 7):fakeStreet.Detail(公开路径)返回 not found,此处 200 且渲染待审标记即证明走 DetailAdmin。
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps/s001DESke", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Snap 1") {
t.Fatalf("街拍详情异常: %d %s", w.Code, w.Body.String())
if w.Code != 200 || !strings.Contains(w.Body.String(), "Snap 1") || !strings.Contains(w.Body.String(), "(待审)") {
t.Fatalf("后台应能渲染待审街拍详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
}
}

View File

@ -24,6 +24,8 @@ type MediaCleanupEnqueuer interface {
type ArticleService interface {
List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error)
Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error)
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error)
// AdminList 后台管理列表:返回含季节 / 年份 / 系列等管理字段的走秀项。
AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
@ -151,22 +153,46 @@ func excludeCover(imgs []dto.PublicArticleImage, cover string) []dto.PublicArtic
return kept
}
// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言;
// 图片统一走展示样式(VIP 与免费同质量)。
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrArticleNotFound)。
// locale 决定标题/描述/品牌名的展示语言;图片统一走展示样式(VIP 与免费同质量)。
func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
row, err := s.articles.FindByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return nil, ErrArticleNotFound
}
return nil, NewError(http.StatusInternalServerError, err.Error())
return nil, s.detailErr(err)
}
imgs, err := s.articles.ListImages(ctx, id)
if err != nil {
return nil, internalErr(err.Error())
}
return s.buildDetail(row, imgs, locale), nil
}
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
//
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
// 若后台详情也走公开视图,待审项会 404、审核无从下手。
func (s *articleService) DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
row, err := s.articles.FindByIDBase(ctx, id)
if err != nil {
return nil, s.detailErr(err)
}
imgs, err := s.articles.ListImagesBase(ctx, id)
if err != nil {
return nil, internalErr(err.Error())
}
return s.buildDetail(row, imgs, locale), nil
}
// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。
func (s *articleService) detailErr(err error) error {
if errors.Is(err, repository.ErrNotFound) {
return ErrArticleNotFound
}
return NewError(http.StatusInternalServerError, err.Error())
}
// buildDetail 把查询行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRunwayImage, locale string) *dto.PublicArticleDetail {
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 look_index
// 直接挂到所属主图的 Detail 子数组下 —— 「look → 细节」的层级与前端 UI 一致,
// 前端读 img.detail 即可,无需再单独维护一份「按 look_index 分组」的映射表。
@ -198,7 +224,7 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string) (
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
Images: pubMains,
}, nil
}
}
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────

View File

@ -16,6 +16,9 @@ import (
// BrandService 品牌业务接口。
type BrandService interface {
List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error)
// AdminList 后台品牌列表:字母索引不要求走秀已发布,
// 使只有待审 / 驳回走秀的品牌仍出现在后台(公开 List 读视图,会挡掉它们)。
AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error)
// Hot 返回热门品牌(按走秀图片数排名前 limit 个)并附带各品牌档案数。
// 供 SSG 构建期(RunwayLooks 侧栏 / 品牌弹窗 HOT 标签)一次性取前 N 个热门品牌;
// 复用 List 的 featured 收窄逻辑(FeaturedIDs("images", limit)),与首页精选口径一致。
@ -70,6 +73,18 @@ func (s *brandService) List(ctx context.Context, q dto.BrandQuery) ([]dto.Public
return s.toPublicBrands(items, q.Locale), total, nil
}
// AdminList 后台品牌列表:与 List 同形,但走 ListAdmin —— 字母索引不过滤走秀发布态,
// 只有待审 / 驳回走秀的品牌仍可见。后台不做 featured 收窄,故 restrictIDs 传 nil。
func (s *brandService) AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
q.Normalize()
items, total, err := s.brands.ListAdmin(ctx, q, nil)
if err != nil {
return nil, 0, internalErr(err.Error())
}
return s.toPublicBrands(items, q.Locale), total, nil
}
// Hot 返回热门品牌:按走秀图片总数排名取前 limit 个,结果按热度顺序(而非字母序)排列。
//
// 供 SSG 构建期(走秀页侧栏品牌 filter / 品牌弹窗 HOT 标签)一次性取用——

View File

@ -20,6 +20,8 @@ type StreetSnapService interface {
// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。
AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
@ -144,27 +146,52 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA
return kept
}
// Detail 查询街拍详情,含完整图片集。图片统一走展示样式(VIP 与免费同质量)。
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。
// 图片统一走展示样式(VIP 与免费同质量)。
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
snap, err := s.repo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return nil, ErrStreetSnapNotFound
}
return nil, internalErr(err.Error())
return nil, s.detailErr(err)
}
imgs, err := s.repo.ListImages(ctx, id)
if err != nil {
return nil, internalErr(err.Error())
}
return s.buildSnapDetail(snap, imgs), nil
}
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
//
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
// 若后台详情也走公开视图,待审项会 404、审核无从下手。
func (s *streetSnapService) DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
snap, err := s.repo.FindByIDBase(ctx, id)
if err != nil {
return nil, s.detailErr(err)
}
imgs, err := s.repo.ListImagesBase(ctx, id)
if err != nil {
return nil, internalErr(err.Error())
}
return s.buildSnapDetail(snap, imgs), nil
}
// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。
func (s *streetSnapService) detailErr(err error) error {
if errors.Is(err, repository.ErrNotFound) {
return ErrStreetSnapNotFound
}
return internalErr(err.Error())
}
// buildSnapDetail 把街拍行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model.StreetSnapImage) *dto.PublicStreetSnapDetail {
return &dto.PublicStreetSnapDetail{
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
Title: snap.Title,
Cover: s.img.Compose(snap.Cover),
Images: s.buildSnapImages(imgs),
}, nil
}
}
// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):