refactor(publish): 公开读改走只读视图,后台仍读基表
This commit is contained in:
@ -142,7 +142,7 @@ func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]mod
|
||||
|
||||
var total int64
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Table(publicRunways).
|
||||
Scopes(scope).
|
||||
Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
@ -153,7 +153,7 @@ func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]mod
|
||||
|
||||
var rows []model.RunwayRow
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Table(publicRunways).
|
||||
Scopes(scope).
|
||||
Select(articleListColumns).
|
||||
Joins(brandJoin).
|
||||
@ -199,7 +199,7 @@ func (r *articleRepository) ListAdmin(ctx context.Context, q dto.ArticleQuery) (
|
||||
func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) {
|
||||
var row model.RunwayRow
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Table(publicRunways).
|
||||
Select(articleDetailColumns).
|
||||
Joins(brandJoin).
|
||||
Where("brand_runways.id = ? AND brand_runways.is_deleted = 0", id).
|
||||
@ -213,7 +213,19 @@ func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.Run
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// ListImages 公开读:读公开视图,未发布走秀的图片不可见(视图已含 is_deleted 过滤)。
|
||||
func (r *articleRepository) ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
|
||||
var imgs []model.BrandRunwayImage
|
||||
err := r.db.WithContext(ctx).
|
||||
Table(publicRunwayImages).
|
||||
Where("runway_id = ?", runwayID).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&imgs).Error
|
||||
return imgs, err
|
||||
}
|
||||
|
||||
// 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).
|
||||
@ -230,8 +242,9 @@ func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32)
|
||||
|
||||
var imgs []model.BrandRunwayImage
|
||||
err := r.db.WithContext(ctx).
|
||||
Table(publicRunwayImages).
|
||||
Select("runway_id, image, name, sort_order").
|
||||
Where("runway_id IN ? AND is_deleted = 0 AND is_detail = 0", ids).
|
||||
Where("runway_id IN ? AND is_detail = 0", ids).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&imgs).Error
|
||||
if err != nil {
|
||||
@ -265,7 +278,7 @@ func (r *articleRepository) GetForEdit(ctx context.Context, id uint32) (*model.B
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
imgs, err := r.ListImages(ctx, strconv.FormatUint(uint64(id), 10))
|
||||
imgs, err := r.listImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@ -71,12 +71,12 @@ 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 {
|
||||
return r.db.Model(&model.BrandRunway{}).
|
||||
Select("DISTINCT brand_id").
|
||||
Where("is_deleted = 0")
|
||||
return r.db.Table(publicRunways).
|
||||
Select("DISTINCT brand_id")
|
||||
}
|
||||
|
||||
// brandFilterScope 把品牌查询条件编译为 GORM Scope。
|
||||
@ -157,10 +157,10 @@ func (r *brandRepository) FeaturedIDs(ctx context.Context, metric string, limit
|
||||
var agg []struct {
|
||||
BrandID uint32 `gorm:"column:brand_id"`
|
||||
}
|
||||
// 读公开视图:热门榜只能由已发布走秀构成,避免把待审/驳回内容的图片数计入热度。
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Table(publicRunways).
|
||||
Select("brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows").
|
||||
Where("is_deleted = 0").
|
||||
Group("brand_id").
|
||||
Order(orderCol + " DESC").
|
||||
Limit(limit).
|
||||
@ -281,7 +281,7 @@ WITH latest_per_brand AS (
|
||||
END DESC,
|
||||
id DESC
|
||||
) AS rn
|
||||
FROM brand_runways
|
||||
FROM public_brand_runways brand_runways
|
||||
WHERE is_deleted = 0 AND brand_id IN (?)
|
||||
)
|
||||
SELECT id, brand_id, cover, title_en, title_cn
|
||||
|
||||
126
internal/repository/public_read_integration_test.go
Normal file
126
internal/repository/public_read_integration_test.go
Normal file
@ -0,0 +1,126 @@
|
||||
//go:build integration
|
||||
|
||||
// 集成测试:公开仓储方法必须读视图(读不到 pending),后台方法必须读基表(读得到 pending)。
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/model"
|
||||
)
|
||||
|
||||
// TestPublicReadHidesUnpublishedRunway 走秀公开列表/详情/图片看不到 pending;后台 GetForEdit 看得到。
|
||||
func TestPublicReadHidesUnpublishedRunway(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
ctx := context.Background()
|
||||
repo := NewArticleRepository(db)
|
||||
|
||||
const year = 1997
|
||||
id := seedRunway(t, db, model.StatusPending, year)
|
||||
idStr := strconv.FormatUint(uint64(id), 10)
|
||||
|
||||
if _, err := repo.FindByID(ctx, idStr); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("pending 走秀详情应 ErrNotFound,实际 %v", err)
|
||||
}
|
||||
imgs, err := repo.ListImages(ctx, idStr)
|
||||
if err != nil {
|
||||
t.Fatalf("ListImages 出错: %v", err)
|
||||
}
|
||||
if len(imgs) != 0 {
|
||||
t.Fatalf("pending 走秀的图片不应公开,实际 %d 张", len(imgs))
|
||||
}
|
||||
_, total, err := repo.List(ctx, dto.ArticleQuery{Year: year, Size: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("List 出错: %v", err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Fatalf("pending 走秀不应出现在公开列表,实际 total=%d", total)
|
||||
}
|
||||
|
||||
// 后台读基表:必须能看到这条待审记录与其图片,否则审核页是空的。
|
||||
rw, baseImgs, err := repo.GetForEdit(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("后台 GetForEdit 应读到 pending 行: %v", err)
|
||||
}
|
||||
if rw == nil || len(baseImgs) == 0 {
|
||||
t.Fatalf("后台应读到 pending 行与其图片,实际 rw=%v imgs=%d", rw != nil, len(baseImgs))
|
||||
}
|
||||
|
||||
// 置为已发布后,公开侧才可见。
|
||||
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
|
||||
t.Fatalf("置为已发布失败: %v", err)
|
||||
}
|
||||
if _, err := repo.FindByID(ctx, idStr); err != nil {
|
||||
t.Fatalf("已发布走秀详情应可读: %v", err)
|
||||
}
|
||||
_, total, err = repo.List(ctx, dto.ArticleQuery{Year: year, Size: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("List 出错: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("已发布走秀应出现在公开列表,实际 total=%d", total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicReadHidesUnpublishedStreet 街拍公开列表/详情/图片看不到 pending;后台 GetForEdit 看得到。
|
||||
func TestPublicReadHidesUnpublishedStreet(t *testing.T) {
|
||||
db := testDB(t)
|
||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||
ctx := context.Background()
|
||||
repo := NewStreetSnapRepository(db)
|
||||
|
||||
const year = 1996
|
||||
id := seedStreetSnap(t, db, model.StatusPending, year)
|
||||
idStr := strconv.FormatUint(uint64(id), 10)
|
||||
|
||||
if _, err := repo.FindByID(ctx, idStr); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("pending 街拍详情应 ErrNotFound,实际 %v", err)
|
||||
}
|
||||
imgs, err := repo.ListImages(ctx, idStr)
|
||||
if err != nil {
|
||||
t.Fatalf("ListImages 出错: %v", err)
|
||||
}
|
||||
if len(imgs) != 0 {
|
||||
t.Fatalf("pending 街拍的图片不应公开,实际 %d 张", len(imgs))
|
||||
}
|
||||
_, total, err := repo.List(ctx, dto.StreetSnapQuery{City: "ViewTestCity", Size: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("List 出错: %v", err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Fatalf("pending 街拍不应出现在公开列表,实际 total=%d", total)
|
||||
}
|
||||
|
||||
snap, baseImgs, err := repo.GetForEdit(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("后台 GetForEdit 应读到 pending 行: %v", err)
|
||||
}
|
||||
if snap == nil || len(baseImgs) == 0 {
|
||||
t.Fatalf("后台应读到 pending 行与其图片,实际 snap=%v imgs=%d", snap != nil, len(baseImgs))
|
||||
}
|
||||
|
||||
// 后台列表 ListAdmin 读基表:必须能看到这条待审记录,否则审核员无从下手。
|
||||
_, adminTotal, err := repo.ListAdmin(ctx, dto.StreetSnapQuery{City: "ViewTestCity", Size: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAdmin 出错: %v", err)
|
||||
}
|
||||
if adminTotal != 1 {
|
||||
t.Fatalf("后台列表应看到 pending 街拍,实际 total=%d", adminTotal)
|
||||
}
|
||||
|
||||
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
|
||||
t.Fatalf("置为已发布失败: %v", err)
|
||||
}
|
||||
_, total, err = repo.List(ctx, dto.StreetSnapQuery{City: "ViewTestCity", Size: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("List 出错: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("已发布街拍应出现在公开列表,实际 total=%d", total)
|
||||
}
|
||||
}
|
||||
19
internal/repository/public_views.go
Normal file
19
internal/repository/public_views.go
Normal file
@ -0,0 +1,19 @@
|
||||
package repository
|
||||
|
||||
// 公开只读视图的表名。
|
||||
//
|
||||
// 把「已发布且未删除」的条件固化在视图里(见 db/migrations/2026-09-22-01-single-table-publish.sql),
|
||||
// 因此公开查询**不可能**漏写可见性过滤 —— 这是单表发布模型相对「每处记得加 status」的核心优势。
|
||||
//
|
||||
// publicRunways 带 `AS brand_runways` 别名:article_repository 里既有的查询大量使用
|
||||
// `brand_runways.xxx` 限定列与 JOIN 条件,起同名别名后这些 SQL 一行都不用改。
|
||||
//
|
||||
// publicStreetSnaps 同样带 `AS street_snaps` 别名:street_snaps 的排序子句 snapOrderBy
|
||||
// 使用 `street_snaps.xxx` 限定列,不带别名会报 missing FROM-clause entry。
|
||||
// 后台读基表时**不要**使用这些常量。
|
||||
const (
|
||||
publicRunways = "public_brand_runways AS brand_runways"
|
||||
publicRunwayImages = "public_brand_runway_images"
|
||||
publicStreetSnaps = "public_street_snaps AS street_snaps"
|
||||
publicStreetSnapImages = "public_street_snap_images"
|
||||
)
|
||||
@ -16,6 +16,8 @@ import (
|
||||
type StreetSnapRepository interface {
|
||||
// List 按筛选条件分页查询街拍,同时返回符合条件的总数。
|
||||
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(ctx context.Context, id string) (*model.StreetSnap, error)
|
||||
// ListImages 查询某条街拍的全部图片,按排序值升序。
|
||||
@ -75,9 +77,39 @@ func snapOrderBy(sort string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// List 公开读:读公开视图,pending / rejected 街拍不可见。
|
||||
func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
|
||||
scope := snapFilterScope(q)
|
||||
|
||||
var total int64
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table(publicStreetSnaps).
|
||||
Scopes(scope).
|
||||
Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if total == 0 {
|
||||
return []model.StreetSnap{}, 0, nil
|
||||
}
|
||||
|
||||
var rows []model.StreetSnap
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table(publicStreetSnaps).
|
||||
Scopes(scope).
|
||||
Order(snapOrderBy(q.Sort)).
|
||||
Offset(q.Offset()).
|
||||
Limit(q.Size).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
// ListAdmin 后台管理列表:与 List 同构,但读基表 —— 后台必须看得到 pending / rejected,
|
||||
// 否则审核员在列表页看不到待审内容(公开 List 读视图,会把这些行挡掉)。
|
||||
func (r *streetSnapRepository) ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
|
||||
scope := snapFilterScope(q)
|
||||
|
||||
var total int64
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnap{}).
|
||||
@ -102,9 +134,11 @@ func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery)
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
// FindByID 公开读:读公开视图,pending / rejected 街拍视同不存在(返回 ErrNotFound)。
|
||||
func (r *streetSnapRepository) FindByID(ctx context.Context, 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
|
||||
if err != nil {
|
||||
@ -116,7 +150,19 @@ func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.
|
||||
return &snap, nil
|
||||
}
|
||||
|
||||
// ListImages 公开读:读公开视图,未发布街拍的图片不可见(视图已含 is_deleted 过滤)。
|
||||
func (r *streetSnapRepository) ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) {
|
||||
var imgs []model.StreetSnapImage
|
||||
err := r.db.WithContext(ctx).
|
||||
Table(publicStreetSnapImages).
|
||||
Where("snap_id = ?", snapID).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&imgs).Error
|
||||
return imgs, err
|
||||
}
|
||||
|
||||
// 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).
|
||||
@ -133,8 +179,9 @@ func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32
|
||||
|
||||
var imgs []model.StreetSnapImage
|
||||
err := r.db.WithContext(ctx).
|
||||
Table(publicStreetSnapImages).
|
||||
Select("snap_id, image, name, sort_order").
|
||||
Where("snap_id IN ? AND is_deleted = 0", ids).
|
||||
Where("snap_id IN ?", ids).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&imgs).Error
|
||||
if err != nil {
|
||||
@ -151,6 +198,7 @@ func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32
|
||||
func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.StreetSnap, error) {
|
||||
var rows []model.StreetSnap
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table(publicStreetSnaps).
|
||||
Where("is_deleted = 0").
|
||||
Order("image_count DESC, id DESC").
|
||||
Limit(limit).
|
||||
@ -178,7 +226,7 @@ func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*mode
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
imgs, err := r.ListImages(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