From f5144038abee128d4811b97234187d3846afaf61 Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Wed, 23 Sep 2026 10:07:43 +0800 Subject: [PATCH] =?UTF-8?q?refactor(publish):=20=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E8=AF=BB=E6=94=B9=E8=B5=B0=E5=8F=AA=E8=AF=BB=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=EF=BC=8C=E5=90=8E=E5=8F=B0=E4=BB=8D=E8=AF=BB=E5=9F=BA=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/backstage_handler.go | 4 +- internal/repository/article_repository.go | 23 +++- internal/repository/brand_repository.go | 14 +- .../public_read_integration_test.go | 126 ++++++++++++++++++ internal/repository/public_views.go | 19 +++ internal/repository/street_snap_repository.go | 52 +++++++- internal/router/backstage_test.go | 6 + internal/service/street_snap_service.go | 39 +++++- 8 files changed, 260 insertions(+), 23 deletions(-) create mode 100644 internal/repository/public_read_integration_test.go create mode 100644 internal/repository/public_views.go diff --git a/internal/handler/backstage_handler.go b/internal/handler/backstage_handler.go index d1a9f0f..ceabb2c 100644 --- a/internal/handler/backstage_handler.go +++ b/internal/handler/backstage_handler.go @@ -242,7 +242,7 @@ func (h *BackstageHandler) RunwayDetail(c *gin.Context) { renderPage(c, http.StatusOK, "runway-detail", gin.H{"Article": art}) } -// StreetSnaps 街拍管理列表:按年份/城市/排序筛选,复用 StreetSnapService.List。 +// StreetSnaps 街拍管理列表:按年份/城市/排序筛选,复用 StreetSnapService.AdminList(读基表,含待审)。 func (h *BackstageHandler) StreetSnaps(c *gin.Context) { q := dto.StreetSnapQuery{ Year: atoiDefault(c.Query("year"), 0), @@ -251,7 +251,7 @@ func (h *BackstageHandler) StreetSnaps(c *gin.Context) { Page: atoiDefault(c.Query("page"), 1), Locale: "en", } - items, total, err := h.street.List(c.Request.Context(), q) + items, total, err := h.street.AdminList(c.Request.Context(), q) if err != nil { renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) return diff --git a/internal/repository/article_repository.go b/internal/repository/article_repository.go index a7f1c47..f44f16c 100644 --- a/internal/repository/article_repository.go +++ b/internal/repository/article_repository.go @@ -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 } diff --git a/internal/repository/brand_repository.go b/internal/repository/brand_repository.go index 009898a..9062603 100644 --- a/internal/repository/brand_repository.go +++ b/internal/repository/brand_repository.go @@ -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 diff --git a/internal/repository/public_read_integration_test.go b/internal/repository/public_read_integration_test.go new file mode 100644 index 0000000..f6c3d12 --- /dev/null +++ b/internal/repository/public_read_integration_test.go @@ -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) + } +} diff --git a/internal/repository/public_views.go b/internal/repository/public_views.go new file mode 100644 index 0000000..8df7afb --- /dev/null +++ b/internal/repository/public_views.go @@ -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" +) diff --git a/internal/repository/street_snap_repository.go b/internal/repository/street_snap_repository.go index b83bb4c..1d583a4 100644 --- a/internal/repository/street_snap_repository.go +++ b/internal/repository/street_snap_repository.go @@ -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 } diff --git a/internal/router/backstage_test.go b/internal/router/backstage_test.go index fdfc9ef..ab289b5 100644 --- a/internal/router/backstage_test.go +++ b/internal/router/backstage_test.go @@ -143,6 +143,12 @@ func (f *fakeStreet) List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.Pub ImageCount: 8, }}, 1, nil } + +// AdminList 后台列表读基表;假实现里与 List 同源即可(本假实现不区分可见性)。 +func (f *fakeStreet) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) { + return f.List(ctx, q) +} + func (f *fakeStreet) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) { return &dto.PublicStreetSnapDetail{ UID: id, diff --git a/internal/service/street_snap_service.go b/internal/service/street_snap_service.go index 6e6effa..6cae782 100644 --- a/internal/service/street_snap_service.go +++ b/internal/service/street_snap_service.go @@ -17,6 +17,8 @@ import ( // StreetSnapService 街拍业务接口。 type StreetSnapService interface { List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) + // AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。 + AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) // Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。 Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) @@ -62,17 +64,11 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([] return nil, 0, internalErr(err.Error()) } - list := make([]dto.PublicStreetSnap, 0, len(rows)) ids := make([]uint32, 0, len(rows)) for _, r := range rows { ids = append(ids, r.ID) - list = append(list, dto.PublicStreetSnap{ - UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap), - Title: r.Title, - Cover: s.img.ComposeThumb(r.Cover), // 列表封面走缩略图样式(小尺寸省流量) - ImageCount: r.ImageCount, - }) } + list := s.toPublicSnaps(rows) if q.Images > 0 && len(list) > 0 { if err := s.attachImages(ctx, list, ids, q.Images); err != nil { @@ -82,6 +78,35 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([] return list, total, nil } +// AdminList 后台街拍列表:读基表,包含 pending / rejected。 +// +// 与 List 用同一个映射(toPublicSnaps),但列表不附图——后台列表只展示卡片字段, +// 与走秀后台 AdminList 的口径一致。 +func (s *streetSnapService) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) { + q.Normalize() + + rows, total, err := s.repo.ListAdmin(ctx, q) + if err != nil { + return nil, 0, internalErr(err.Error()) + } + return s.toPublicSnaps(rows), total, nil +} + +// toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。 +// 公开 List 与后台 AdminList 共用,避免两处重复维护映射(封面统一走缩略图样式,小尺寸省流量)。 +func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap) []dto.PublicStreetSnap { + list := make([]dto.PublicStreetSnap, 0, len(rows)) + for _, r := range rows { + list = append(list, dto.PublicStreetSnap{ + UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap), + Title: r.Title, + Cover: s.img.ComposeThumb(r.Cover), + ImageCount: r.ImageCount, + }) + } + return list +} + // attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐条请求详情。 func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicStreetSnap, ids []uint32, limit int) error { grouped, err := s.repo.ImagesBySnapIDs(ctx, ids)