fix(publish): 后台详情与品牌列表改读基表,修复未发布内容在后台不可达
This commit is contained in:
@ -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
|
||||
}
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user