From 4169ebe1edf5c6876815e2a1d3a3418996e120d5 Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Wed, 23 Sep 2026 10:16:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(publish):=20=E5=90=8E=E5=8F=B0=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E4=B8=8E=E5=93=81=E7=89=8C=E5=88=97=E8=A1=A8=E6=94=B9?= =?UTF-8?q?=E8=AF=BB=E5=9F=BA=E8=A1=A8=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=9C=AA?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=86=85=E5=AE=B9=E5=9C=A8=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E8=BE=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/backstage_handler.go | 21 ++++-- internal/repository/article_repository.go | 30 ++++++-- internal/repository/brand_repository.go | 46 ++++++++++-- .../public_read_integration_test.go | 75 +++++++++++++++++++ internal/repository/street_snap_repository.go | 30 +++++--- internal/router/backstage_test.go | 39 ++++++++-- internal/service/article_service.go | 42 +++++++++-- internal/service/brand_service.go | 15 ++++ internal/service/street_snap_service.go | 41 ++++++++-- 9 files changed, 287 insertions(+), 52 deletions(-) diff --git a/internal/handler/backstage_handler.go b/internal/handler/backstage_handler.go index ceabb2c..3527377 100644 --- a/internal/handler/backstage_handler.go +++ b/internal/handler/backstage_handler.go @@ -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 diff --git a/internal/repository/article_repository.go b/internal/repository/article_repository.go index f44f16c..de0085e 100644 --- a/internal/repository/article_repository.go +++ b/internal/repository/article_repository.go @@ -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 } diff --git a/internal/repository/brand_repository.go b/internal/repository/brand_repository.go index 9062603..a566548 100644 --- a/internal/repository/brand_repository.go +++ b/internal/repository/brand_repository.go @@ -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). diff --git a/internal/repository/public_read_integration_test.go b/internal/repository/public_read_integration_test.go index f6c3d12..5c50157 100644 --- a/internal/repository/public_read_integration_test.go +++ b/internal/repository/public_read_integration_test.go @@ -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) + } +} diff --git a/internal/repository/street_snap_repository.go b/internal/repository/street_snap_repository.go index 1d583a4..17bc73c 100644 --- a/internal/repository/street_snap_repository.go +++ b/internal/repository/street_snap_repository.go @@ -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 } diff --git a/internal/router/backstage_test.go b/internal/router/backstage_test.go index ab289b5..4c6b960 100644 --- a/internal/router/backstage_test.go +++ b/internal/router/backstage_test.go @@ -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()) } } diff --git a/internal/service/article_service.go b/internal/service/article_service.go index 52781bd..d9ee155 100644 --- a/internal/service/article_service.go +++ b/internal/service/article_service.go @@ -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 + } } // ── 后台编辑支持 ────────────────────────────────────────────────────────────── diff --git a/internal/service/brand_service.go b/internal/service/brand_service.go index 2838efb..7febce5 100644 --- a/internal/service/brand_service.go +++ b/internal/service/brand_service.go @@ -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 标签)一次性取用—— diff --git a/internal/service/street_snap_service.go b/internal/service/street_snap_service.go index 6cae782..30360f3 100644 --- a/internal/service/street_snap_service.go +++ b/internal/service/street_snap_service.go @@ -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 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):