From 383b5e042eae0de59269370f5db796f3959bfe2f Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Fri, 24 Jul 2026 19:20:32 +0800 Subject: [PATCH] docs(starbook): add has_more design spec and implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录星册首页 More 按钮恢复的方案说明与 TDD 实施计划,作为本次特性 commit e079a6c 的设计依据。 Co-Authored-By: Claude Fable 5 --- .../2026-07-24-starbook-home-has-more.md | 766 ++++++++++++++++++ ...026-07-24-starbook-home-has-more-design.md | 204 +++++ 2 files changed, 970 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-24-starbook-home-has-more.md create mode 100644 docs/superpowers/specs/2026-07-24-starbook-home-has-more-design.md diff --git a/docs/superpowers/plans/2026-07-24-starbook-home-has-more.md b/docs/superpowers/plans/2026-07-24-starbook-home-has-more.md new file mode 100644 index 0000000..b26dfab --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-starbook-home-has-more.md @@ -0,0 +1,766 @@ +# Starbook Home Has-More Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore the starbook home preview so every regular grade, collection group, and activity group returns the three highest-liked assets and sets `has_more=true` when additional assets exist, while the More endpoint returns every matching asset in the same order. + +**Architecture:** Keep the existing shared group builders and add a `previewLimit int` parameter. A single helper sorts valid `AssetItem` values by authoritative `models.Asset.LikeCount DESC, models.Asset.ID ASC`, truncates only when `previewLimit > 0`, and reports whether truncation occurred. `GetMyAssets` passes `3`; `GetAssetsByType` passes `0`. + +**Tech Stack:** Go 1.25, Dubbo Triple protobuf DTOs, testify, GORM/PostgreSQL integration tests. + +## Global Constraints + +- Use `models.Asset.LikeCount` as the only ordering source; never sort from `AssetRegistry.LikeCount`. +- Stable order is `Asset.LikeCount DESC, Asset.ID ASC` for regular, collection, and activity assets. +- `GetMyAssets` uses the fixed `homePreviewLimit = 3`. +- `GetAssetsByType` uses `previewLimit = 0`, returns all matches, and leaves group/grade `has_more=false`. +- Count only registry rows that resolve to a real `models.Asset`; dangling registries do not affect `total_count` or `has_more`. +- Do not modify `frontend/pages/components/StarbookContent.vue`; its existing `v-if` expressions consume the corrected fields. +- Do not modify `backend/proto/asset.proto` or generated protobuf files for this feature. +- Do not add a new dependency, configuration flag, cache, migration, or repository pagination abstraction. +- Do not run `git commit` or `git push`; repository rules prohibit AI commits unless explicitly requested. + +--- + +## File Structure + +- Create `backend/services/assetService/service/asset_service_group_test.go`: DB-free unit tests for sorting, preview truncation, valid-item counting, authoritative like source, and all three group builders. +- Modify `backend/services/assetService/service/asset_service.go`: add the preview constant/helper, parameterize the three builders, and pass the correct limit from the home and More flows. +- Modify `backend/services/assetService/provider/asset_provider_test.go`: add a DB-backed end-to-end regression proving `GetMyAssets` returns top three while `GetAssetsByType` returns all four in the same order. +- No frontend or protobuf source/generated files change. + +### Task 1: Add the shared ordering helper and regular-grade preview + +**Files:** +- Create: `backend/services/assetService/service/asset_service_group_test.go` +- Modify: `backend/services/assetService/service/asset_service.go:22-46,159-183,309-324,371-438` + +**Interfaces:** +- Produces: `const homePreviewLimit = 3` +- Produces: `sortAndLimitAssetItems(items []*pb.AssetItem, previewLimit int) ([]*pb.AssetItem, bool)` +- Changes: `buildRegularGroupForAssets(..., previewLimit int) *pb.AssetGroup` +- `previewLimit <= 0` means sorted but unlimited, with `hasMore=false`. + +- [ ] **Step 1: Write the failing regular preview tests** + +Create `backend/services/assetService/service/asset_service_group_test.go`: + +```go +package service + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/topfans/backend/pkg/models" + pb "github.com/topfans/backend/pkg/proto/asset" +) + +func assetIDs(items []*pb.AssetItem) []int64 { + ids := make([]int64, 0, len(items)) + for _, item := range items { + ids = append(ids, item.AssetId) + } + return ids +} + +func rankedAssets() []*models.Asset { + return []*models.Asset{ + {ID: 1, Name: "ten", LikeCount: 10}, + {ID: 2, Name: "thirty-first", LikeCount: 30}, + {ID: 3, Name: "twenty", LikeCount: 20}, + {ID: 4, Name: "thirty-second", LikeCount: 30}, + } +} + +func TestBuildRegularGroupForAssets_HomePreviewUsesAssetLikes(t *testing.T) { + grade := int32(2) + registries := []*models.AssetRegistry{ + {AssetID: 1, AssetType: models.AssetTypeRegular, Grade: &grade, LikeCount: 1000}, + {AssetID: 2, AssetType: models.AssetTypeRegular, Grade: &grade, LikeCount: 1}, + {AssetID: 3, AssetType: models.AssetTypeRegular, Grade: &grade, LikeCount: 900}, + {AssetID: 4, AssetType: models.AssetTypeRegular, Grade: &grade, LikeCount: 2}, + {AssetID: 999, AssetType: models.AssetTypeRegular, Grade: &grade, LikeCount: 9999}, + } + + group := (&assetService{}).buildRegularGroupForAssets(rankedAssets(), nil, registries, 3) + + require.NotNil(t, group) + require.Len(t, group.Grades, 1) + section := group.Grades[0] + assert.Equal(t, []int64{2, 4, 3}, assetIDs(section.Items)) + assert.Equal(t, int32(4), section.TotalCount, "dangling registry must not count") + assert.True(t, section.HasMore) + assert.Equal(t, int32(4), group.TotalCount) + assert.True(t, group.HasMore) +} + +func TestBuildRegularGroupForAssets_UnlimitedReturnsAllSorted(t *testing.T) { + grade := int32(2) + registries := []*models.AssetRegistry{ + {AssetID: 1, AssetType: models.AssetTypeRegular, Grade: &grade}, + {AssetID: 2, AssetType: models.AssetTypeRegular, Grade: &grade}, + {AssetID: 3, AssetType: models.AssetTypeRegular, Grade: &grade}, + {AssetID: 4, AssetType: models.AssetTypeRegular, Grade: &grade}, + } + + group := (&assetService{}).buildRegularGroupForAssets(rankedAssets(), nil, registries, 0) + + require.Len(t, group.Grades, 1) + assert.Equal(t, []int64{2, 4, 3, 1}, assetIDs(group.Grades[0].Items)) + assert.False(t, group.Grades[0].HasMore) + assert.False(t, group.HasMore) +} +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```bash +go -C backend/services/assetService test -count=1 ./service -run 'TestBuildRegularGroupForAssets' -v +``` + +Expected: compilation fails because `buildRegularGroupForAssets` currently accepts three arguments, not four. This is the intended RED failure. + +- [ ] **Step 3: Add the shared preview constant and helper** + +In `backend/services/assetService/service/asset_service.go`, add near the service definitions: + +```go +const homePreviewLimit = 3 +``` + +Add after `registryLogicalCategory`: + +```go +// sortAndLimitAssetItems applies the starbook ordering contract and optionally +// truncates a home-page preview. previewLimit <= 0 means unlimited. +func sortAndLimitAssetItems(items []*pb.AssetItem, previewLimit int) ([]*pb.AssetItem, bool) { + sort.Slice(items, func(i, j int) bool { + if items[i].LikeCount != items[j].LikeCount { + return items[i].LikeCount > items[j].LikeCount + } + return items[i].AssetId < items[j].AssetId + }) + + if previewLimit > 0 && len(items) > previewLimit { + return items[:previewLimit], true + } + return items, false +} +``` + +- [ ] **Step 4: Parameterize and implement the regular builder** + +Replace `buildRegularGroupForAssets` with: + +```go +// buildRegularGroupForAssets 构建原创藏品分组 +func (s *assetService) buildRegularGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry, previewLimit int) *pb.AssetGroup { + assetMap := make(map[int64]*models.Asset) + for _, asset := range allAssets { + assetMap[asset.ID] = asset + } + + gradeGroups := make(map[int32][]*models.AssetRegistry) + for _, reg := range registries { + if reg.Grade != nil { + gradeGroups[*reg.Grade] = append(gradeGroups[*reg.Grade], reg) + } + } + + grades := make([]*pb.GradeSection, 0) + for grade, regs := range gradeGroups { + items := make([]*pb.AssetItem, 0, len(regs)) + for _, reg := range regs { + asset := assetMap[reg.AssetID] + if asset == nil { + continue + } + items = append(items, &pb.AssetItem{ + AssetId: asset.ID, + Name: asset.Name, + CoverUrlSigned: asset.CoverURL, + LikeCount: asset.LikeCount, + CreatedAt: asset.CreatedAt, + Category: "castlove", + Grade: grade, + DisplayStatus: reg.DisplayStatus, + }) + } + + totalCount := int32(len(items)) + items, hasMore := sortAndLimitAssetItems(items, previewLimit) + grades = append(grades, &pb.GradeSection{ + Grade: grade, + Items: items, + TotalCount: totalCount, + HasMore: hasMore, + }) + } + + sort.Slice(grades, func(i, j int) bool { + return grades[i].Grade > grades[j].Grade + }) + + totalCount := int32(0) + hasMore := false + for _, grade := range grades { + totalCount += grade.TotalCount + hasMore = hasMore || grade.HasMore + } + + return &pb.AssetGroup{ + Type: models.AssetTypeRegular, + Category: "castlove", + CategoryName: "原创", + Grades: grades, + TotalCount: totalCount, + HasMore: hasMore, + } +} +``` + +Keep the existing `registryMap` parameter in this task to avoid an unrelated signature/refactor change; it remains part of all three existing builder contracts. + +- [ ] **Step 5: Pass the correct limit at regular call sites** + +In `GetMyAssets`: + +```go +if regs, ok := typeGroups[models.AssetTypeRegular]; ok { + group := s.buildRegularGroupForAssets(allAssets, registryMap, regs, homePreviewLimit) + if group != nil { + groups = append(groups, group) + } +} +``` + +In `GetAssetsByType`: + +```go +if regs, ok := typeGroups[models.AssetTypeRegular]; ok { + if group := s.buildRegularGroupForAssets(allAssets, registryMap, regs, 0); group != nil { + groups = append(groups, group) + } +} +``` + +- [ ] **Step 6: Run the regular tests and verify GREEN** + +Run: + +```bash +gofmt -w backend/services/assetService/service/asset_service.go backend/services/assetService/service/asset_service_group_test.go +go -C backend/services/assetService test -count=1 ./service -run 'TestBuildRegularGroupForAssets' -v +``` + +Expected: both regular tests pass; no DB is required. + +- [ ] **Step 7: Review checkpoint** + +Run: + +```bash +git diff --check -- backend/services/assetService/service/asset_service.go backend/services/assetService/service/asset_service_group_test.go +git diff -- backend/services/assetService/service/asset_service.go backend/services/assetService/service/asset_service_group_test.go +``` + +Confirm the diff contains only the shared helper, regular preview behavior, call-site limits, and tests. Do not commit. + +### Task 2: Apply the same contract to collection and activity groups + +**Files:** +- Modify: `backend/services/assetService/service/asset_service_group_test.go` +- Modify: `backend/services/assetService/service/asset_service.go:169-183,315-324,440-522` + +**Interfaces:** +- Consumes: `sortAndLimitAssetItems(items []*pb.AssetItem, previewLimit int) ([]*pb.AssetItem, bool)` from Task 1. +- Changes: `buildCollectionGroupForAssets(..., previewLimit int) *pb.AssetGroup` +- Changes: `buildActivityGroupForAssets(..., previewLimit int) *pb.AssetGroup` + +- [ ] **Step 1: Append failing collection and activity tests** + +Append to `asset_service_group_test.go`: + +```go +func strPtr(value string) *string { return &value } + +func TestBuildCollectionGroupForAssets_PreviewAndUnlimited(t *testing.T) { + registries := []*models.AssetRegistry{ + {AssetID: 1, AssetType: models.AssetTypeCollection, CollectionCategory: strPtr("cards")}, + {AssetID: 2, AssetType: models.AssetTypeCollection, CollectionCategory: strPtr("cards")}, + {AssetID: 3, AssetType: models.AssetTypeCollection, CollectionCategory: strPtr("cards")}, + {AssetID: 4, AssetType: models.AssetTypeCollection, CollectionCategory: strPtr("cards")}, + {AssetID: 999, AssetType: models.AssetTypeCollection, CollectionCategory: strPtr("cards")}, + } + svc := &assetService{} + + preview := svc.buildCollectionGroupForAssets(rankedAssets(), nil, registries, 3) + assert.Equal(t, []int64{2, 4, 3}, assetIDs(preview.Items)) + assert.Equal(t, int32(4), preview.TotalCount) + assert.True(t, preview.HasMore) + + full := svc.buildCollectionGroupForAssets(rankedAssets(), nil, registries, 0) + assert.Equal(t, []int64{2, 4, 3, 1}, assetIDs(full.Items)) + assert.Equal(t, int32(4), full.TotalCount) + assert.False(t, full.HasMore) +} + +func TestBuildActivityGroupForAssets_PreviewAndUnlimited(t *testing.T) { + registries := []*models.AssetRegistry{ + {AssetID: 1, AssetType: models.AssetTypeActivity, ActivityType: strPtr("offline")}, + {AssetID: 2, AssetType: models.AssetTypeActivity, ActivityType: strPtr("offline")}, + {AssetID: 3, AssetType: models.AssetTypeActivity, ActivityType: strPtr("offline")}, + {AssetID: 4, AssetType: models.AssetTypeActivity, ActivityType: strPtr("offline")}, + {AssetID: 999, AssetType: models.AssetTypeActivity, ActivityType: strPtr("offline")}, + } + svc := &assetService{} + + preview := svc.buildActivityGroupForAssets(rankedAssets(), nil, registries, 3) + assert.Equal(t, []int64{2, 4, 3}, assetIDs(preview.Items)) + assert.Equal(t, int32(4), preview.TotalCount) + assert.True(t, preview.HasMore) + + full := svc.buildActivityGroupForAssets(rankedAssets(), nil, registries, 0) + assert.Equal(t, []int64{2, 4, 3, 1}, assetIDs(full.Items)) + assert.Equal(t, int32(4), full.TotalCount) + assert.False(t, full.HasMore) +} +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```bash +go -C backend/services/assetService test -count=1 ./service -run 'TestBuild(Collection|Activity)GroupForAssets' -v +``` + +Expected: compilation fails because the collection/activity builders still accept three arguments. + +- [ ] **Step 3: Parameterize the collection builder** + +Replace the collection builder with: + +```go +// buildCollectionGroupForAssets 构建典收藏品分组 +func (s *assetService) buildCollectionGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry, previewLimit int) *pb.AssetGroup { + assetMap := make(map[int64]*models.Asset) + for _, asset := range allAssets { + assetMap[asset.ID] = asset + } + + items := make([]*pb.AssetItem, 0, len(registries)) + for _, reg := range registries { + asset := assetMap[reg.AssetID] + if asset == nil { + continue + } + category := "" + if reg.CollectionCategory != nil { + category = *reg.CollectionCategory + } + items = append(items, &pb.AssetItem{ + AssetId: asset.ID, + Name: asset.Name, + CoverUrlSigned: asset.CoverURL, + LikeCount: asset.LikeCount, + CreatedAt: asset.CreatedAt, + Category: category, + Grade: 0, + DisplayStatus: reg.DisplayStatus, + }) + } + + totalCount := int32(len(items)) + items, hasMore := sortAndLimitAssetItems(items, previewLimit) + return &pb.AssetGroup{ + Type: models.AssetTypeCollection, + Category: "", + CategoryName: "典藏", + Items: items, + TotalCount: totalCount, + HasMore: hasMore, + } +} +``` + +- [ ] **Step 4: Parameterize the activity builder** + +Replace the activity builder with: + +```go +// buildActivityGroupForAssets 构建活动藏品分组 +func (s *assetService) buildActivityGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry, previewLimit int) *pb.AssetGroup { + assetMap := make(map[int64]*models.Asset) + for _, asset := range allAssets { + assetMap[asset.ID] = asset + } + + items := make([]*pb.AssetItem, 0, len(registries)) + for _, reg := range registries { + asset := assetMap[reg.AssetID] + if asset == nil { + continue + } + activityType := "" + if reg.ActivityType != nil { + activityType = *reg.ActivityType + } + items = append(items, &pb.AssetItem{ + AssetId: asset.ID, + Name: asset.Name, + CoverUrlSigned: asset.CoverURL, + LikeCount: asset.LikeCount, + CreatedAt: asset.CreatedAt, + Category: activityType, + Grade: 0, + DisplayStatus: reg.DisplayStatus, + }) + } + + totalCount := int32(len(items)) + items, hasMore := sortAndLimitAssetItems(items, previewLimit) + return &pb.AssetGroup{ + Type: models.AssetTypeActivity, + Category: "", + CategoryName: "活动", + Items: items, + TotalCount: totalCount, + HasMore: hasMore, + } +} +``` + +- [ ] **Step 5: Pass limits at all collection/activity call sites** + +In `GetMyAssets`, pass `homePreviewLimit`: + +```go +if regs, ok := typeGroups[models.AssetTypeCollection]; ok { + group := s.buildCollectionGroupForAssets(allAssets, registryMap, regs, homePreviewLimit) + if group != nil { + groups = append(groups, group) + } +} +if regs, ok := typeGroups[models.AssetTypeActivity]; ok { + group := s.buildActivityGroupForAssets(allAssets, registryMap, regs, homePreviewLimit) + if group != nil { + groups = append(groups, group) + } +} +``` + +In `GetAssetsByType`, pass `0`: + +```go +if regs, ok := typeGroups[models.AssetTypeCollection]; ok { + if group := s.buildCollectionGroupForAssets(allAssets, registryMap, regs, 0); group != nil { + groups = append(groups, group) + } +} +if regs, ok := typeGroups[models.AssetTypeActivity]; ok { + if group := s.buildActivityGroupForAssets(allAssets, registryMap, regs, 0); group != nil { + groups = append(groups, group) + } +} +``` + +- [ ] **Step 6: Run all DB-free group tests and verify GREEN** + +Run: + +```bash +gofmt -w backend/services/assetService/service/asset_service.go backend/services/assetService/service/asset_service_group_test.go +go -C backend/services/assetService test -count=1 ./service -run 'TestBuild(Regular|Collection|Activity)GroupForAssets' -v +``` + +Expected: all four group-builder tests pass without a database. + +- [ ] **Step 7: Review checkpoint** + +Run: + +```bash +git diff --check -- backend/services/assetService/service/asset_service.go backend/services/assetService/service/asset_service_group_test.go +git diff --stat +``` + +Confirm no frontend, proto, generated proto, migration, or dependency file was changed by this task. Do not commit. + +### Task 3: Verify the home and More call paths end-to-end + +**Files:** +- Modify: `backend/services/assetService/provider/asset_provider_test.go:89-145,206-220` + +**Interfaces:** +- Consumes: `GetMyAssets` with `homePreviewLimit=3` wired internally. +- Consumes: `GetAssetsByType` with `previewLimit=0` wired internally. +- Verifies: both paths use `Asset.LikeCount DESC, Asset.ID ASC`. + +- [ ] **Step 1: Add a ranked collection fixture helper** + +Append after `sbSeed` in `asset_provider_test.go`: + +```go +func sbAddRankedCollectionAssets(t *testing.T, db *gorm.DB) { + t.Helper() + + var existing models.Asset + require.NoError(t, db.Where( + "owner_uid = ? AND star_id = ? AND name = ?", + sbTestOwnerUID, sbTestStarID, "collection_x", + ).First(&existing).Error) + require.NoError(t, db.Model(&existing).Update("like_count", int32(5)).Error) + require.NoError(t, db.Model(&models.AssetRegistry{}). + Where("asset_id = ?", existing.ID). + Update("like_count", int32(999)).Error) + + category := "手办" + now := time.Now().UnixMilli() + for index, likeCount := range []int32{20, 40, 30} { + asset := &models.Asset{ + OwnerUID: sbTestOwnerUID, + StarID: sbTestStarID, + Name: fmt.Sprintf("collection_ranked_%d", index), + CoverURL: fmt.Sprintf("https://cdn/collection_ranked_%d.png", index), + Status: 1, + IsActive: true, + LikeCount: likeCount, + CreatedAt: now + int64(index+1), + UpdatedAt: now + int64(index+1), + } + require.NoError(t, db.Create(asset).Error) + require.NoError(t, db.Create(&models.AssetRegistry{ + AssetID: asset.ID, + AssetType: models.AssetTypeCollection, + OwnerUID: sbTestOwnerUID, + StarID: sbTestStarID, + CollectionCategory: &category, + LikeCount: 1000 - likeCount, + DisplayStatus: 1, + }).Error) + } +} + +func requireGroupByType(t *testing.T, groups []*pb.AssetGroup, assetType string) *pb.AssetGroup { + t.Helper() + for _, group := range groups { + if group.Type == assetType { + return group + } + } + t.Fatalf("group type %q not found", assetType) + return nil +} + +func itemLikeCounts(items []*pb.AssetItem) []int32 { + counts := make([]int32, 0, len(items)) + for _, item := range items { + counts = append(counts, item.LikeCount) + } + return counts +} +``` + +Add `fmt` to the existing import block. + +- [ ] **Step 2: Add the failing home-versus-More regression test** + +Append after `TestGetAssetsByType_FilterByCategory`: + +```go +func TestStarbookHomePreviewAndMoreUseAssetLikeRanking(t *testing.T) { + db := starbookTestDB(t) + sbSeed(t, db) + sbAddRankedCollectionAssets(t, db) + provider := sbProvider(db) + ctx := sbCtx(sbTestOwnerUID, sbTestStarID) + + homeResp, err := provider.GetMyAssets(ctx, &pb.GetMyAssetsRequest{}) + require.NoError(t, err) + require.NotNil(t, homeResp.Data) + homeGroup := requireGroupByType(t, homeResp.Data.Groups, models.AssetTypeCollection) + assert.Equal(t, []int32{40, 30, 20}, itemLikeCounts(homeGroup.Items)) + assert.Equal(t, int32(4), homeGroup.TotalCount) + assert.True(t, homeGroup.HasMore) + + moreResp, err := provider.GetAssetsByType(ctx, &pb.GetAssetsByTypeRequest{ + Type: models.AssetTypeCollection, + Category: "手办", + }) + require.NoError(t, err) + require.NotNil(t, moreResp.Data) + moreGroup := requireGroupByType(t, moreResp.Data.Groups, models.AssetTypeCollection) + assert.Equal(t, []int32{40, 30, 20, 5}, itemLikeCounts(moreGroup.Items)) + assert.Equal(t, int32(4), moreGroup.TotalCount) + assert.False(t, moreGroup.HasMore) +} +``` + +- [ ] **Step 3: Prove the integration test would catch reversed call-site limits** + +Before accepting GREEN, temporarily change only the `GetMyAssets` collection call from `homePreviewLimit` to `0`, then run: + +```bash +go -C backend/services/assetService test -count=1 ./provider -run '^TestStarbookHomePreviewAndMoreUseAssetLikeRanking$' -v +``` + +Expected: FAIL because home returns four items instead of `[40, 30, 20]` and `HasMore` is false. Restore `homePreviewLimit` immediately after observing the expected failure. + +- [ ] **Step 4: Run the restored integration test and verify GREEN** + +Run: + +```bash +gofmt -w backend/services/assetService/provider/asset_provider_test.go +go -C backend/services/assetService test -count=1 ./provider -run 'Test(GetAssetsByType|StarbookHomePreviewAndMore)' -v +``` + +Expected: existing filter/auth tests and the new home/More ranking test pass. If PostgreSQL is unavailable, the DB-backed tests explicitly report SKIP; the DB-free Task 1/2 tests must still pass and are not optional. + +- [ ] **Step 5: Review checkpoint** + +Run: + +```bash +git diff --check -- backend/services/assetService/provider/asset_provider_test.go +git diff -- backend/services/assetService/provider/asset_provider_test.go +``` + +Confirm the fixture manually specifies no IDs, so the PostgreSQL sequence rule is not newly triggered. Existing `sbSeed` manual user/star IDs retain their existing `setval` calls. Do not commit. + +### Task 4: Full regression and cross-impact verification + +**Files:** +- Verify only; no planned source changes. + +**Interfaces:** +- Verifies all outputs from Tasks 1-3. + +- [ ] **Step 1: Run the complete DB-free group regression** + +Run: + +```bash +go -C backend/services/assetService test -count=1 ./service -run 'TestBuild(Regular|Collection|Activity)GroupForAssets' -v +``` + +Expected: all group-builder tests pass. + +- [ ] **Step 2: Run starbook provider and gateway tests** + +Run: + +```bash +go -C backend/services/assetService test -count=1 ./provider -run 'Test(GetAssetsByType|StarbookHomePreviewAndMore)' -v +go -C backend/gateway test -count=1 ./controller -run '^TestStarbook' -v +``` + +Expected: all selected tests pass, or only DB-backed provider tests explicitly SKIP because PostgreSQL is unavailable. No selected test may fail. + +- [ ] **Step 3: Compile gateway and assetService** + +Run: + +```bash +go -C backend/gateway build ./... +go -C backend/services/assetService build ./... +``` + +Expected: both commands exit 0. + +- [ ] **Step 4: Precompile every service from `dev.sh` without overwriting repository binaries** + +Run: + +```bash +tmp=$(mktemp -d /tmp/topfans-starbook-build.XXXXXX) +trap 'rm -rf "$tmp"' EXIT +for spec in \ + 'gateway:gateway' \ + 'userService:services/userService' \ + 'assetService:services/assetService' \ + 'socialService:services/socialService' \ + 'galleryService:services/galleryService' \ + 'activityService:services/activityService' \ + 'taskService:services/taskService' \ + 'aiChatService:services/aiChatService' \ + 'statisticService:services/statisticService' \ + 'notificationService:services/notificationService' \ + 'moderationService:services/moderationService'; do + name=${spec%%:*} + dir=${spec#*:} + go -C "backend/$dir" build -o "$tmp/$name" . || exit 1 +done +``` + +Expected: all 11 builds exit 0. + +- [ ] **Step 5: Re-run graph cross-impact checks** + +Using code-review-graph: + +1. Incrementally update the graph for `backend/services/assetService/service/asset_service.go` and the two test files. +2. Query `callers_of` for all three `build*GroupForAssets` functions. +3. Verify `GetMyAssets` passes `homePreviewLimit` and `GetAssetsByType` passes `0` for regular, collection, and activity. +4. Query `tests_for` for `GetMyAssets` and `GetAssetsByType`; record any graph limitation if the new tests are not linked automatically. + +Expected: no stale three-argument builder caller remains. + +- [ ] **Step 6: Perform the mandatory global self-review** + +Check every source requirement against the diff: + +```text +[ ] regular uses Asset.LikeCount DESC, Asset.ID ASC +[ ] collection uses Asset.LikeCount DESC, Asset.ID ASC +[ ] activity uses Asset.LikeCount DESC, Asset.ID ASC +[ ] home returns at most 3 per regular grade / collection group / activity group +[ ] home has_more is true only when valid items were omitted +[ ] More returns all valid items in the same order +[ ] dangling registries do not affect total_count or has_more +[ ] frontend and protobuf files are unchanged by this feature +[ ] existing unrelated working-tree changes remain intact +``` + +Run: + +```bash +git diff --check +git status --short +git diff --stat +``` + +Expected: no whitespace errors; only intended feature files plus the pre-existing protobuf/gateway changes and design/plan documents are present. Do not commit or push. + +--- + +## Plan Self-Review + +### Spec coverage + +- Home limit 3: Tasks 1-2 implement; Task 3 verifies through `GetMyAssets`. +- Highest likes first for all types: shared helper in Task 1, all builders in Tasks 1-2. +- Authoritative source is `Asset.LikeCount`: fixture conflicts with registry likes in Tasks 1 and 3. +- Stable tie ordering `Asset.ID ASC`: regular test IDs 2 and 4 both have 30 likes; expected order proves the tie rule. +- `has_more`: builder tests cover preview and unlimited modes; integration verifies serialization DTO values before gateway JSON. +- More returns all: Tasks 1-2 unit tests plus Task 3 end-to-end test. +- Invalid registry exclusion: every builder test includes asset ID 999 without a matching asset. +- No frontend/proto changes: enforced in global constraints and Task 4 review. +- Regression: targeted tests, two builds, all-service precompile, graph checks, and global self-review are explicit. + +### Placeholder scan + +The plan contains no `TBD`, `TODO`, “implement later,” unspecified test request, or undefined interface. Every code-changing step includes concrete code and an exact verification command. + +### Type consistency + +- `LikeCount` is `int32` in `models.Asset`, `models.AssetRegistry`, and `pb.AssetItem`. +- `Asset.ID` and `pb.AssetItem.AssetId` are `int64`. +- `previewLimit` is consistently `int`. +- The helper returns `([]*pb.AssetItem, bool)` and every builder consumes both return values. +- `TotalCount` remains protobuf-compatible `int32`. diff --git a/docs/superpowers/specs/2026-07-24-starbook-home-has-more-design.md b/docs/superpowers/specs/2026-07-24-starbook-home-has-more-design.md new file mode 100644 index 0000000..27bb70a --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-starbook-home-has-more-design.md @@ -0,0 +1,204 @@ +# 星册首页“更多”按钮恢复设计 + +> ★ **MVP 优先**:本次仅恢复旧版星册首页“每组预览 3 张 + 超出显示更多”的既有行为,不引入新分页框架、不修改前端布局。 + +## 方案概述(必读) + +### 要解决的问题 + +**业务问题** + +- 星册首页的原创、典藏和活动分组均不再显示“更多”按钮。 +- 用户无法从首页进入对应分类的完整藏品列表。 + +**技术问题** + +- `assetService` 迁移后,分组构建器返回全部藏品,并把 `AssetGroup.HasMore`、`GradeSection.HasMore` 固定为 `false`。 +- protobuf 的 `false` 字段可能因 `omitempty` 不出现在 JSON 中,前端 `v-if="group.has_more"` 和 `v-if="gradeItem.has_more"` 均不成立。 +- 当前分组构建器同时被首页 `GetMyAssets` 和查看更多 `GetAssetsByType` 复用,不能直接统一截断,否则查看更多页面也只能拿到前三张。 + +### 整体实现路径 + +1. 为共享分组构建器增加预览上限参数,约 0.5 小时。 +2. 首页传入固定上限 3,查看更多传入 0(不限),约 0.5 小时。 +3. 补充首页截断和查看更多完整返回测试,约 1 小时。 +4. 执行 assetService、gateway 相关测试和全服务预编译,约 0.5 小时。 + +预计总工作量:约 2.5 小时。 + +### 关键决策 + +- **恢复旧版 `HomePageSize = 3` 行为**:用户已确认每个分组或等级最多预览 3 张。 +- **统一按点赞数选取首页前三张**:原创、典藏、活动均使用 `models.Asset.LikeCount` 按点赞数降序;点赞数相同时按 `asset_id` 升序,沿用项目排行榜的稳定排序口径。 +- **后端计算 `has_more`**:保证 API 契约真实,避免前端下载全部数据后再截断。 +- **参数化复用现有构建器**:首页限制 3,查看更多限制 0,避免复制三套分组逻辑。 +- **不修改前端模板**:现有 `v-if` 在后端返回正确字段后即可工作。 + +### 核心架构图(TL;DR) + +```text +GET /api/v1/starbook/home + │ + ▼ +GetMyAssets ── previewLimit=3 ──► group builders + │ + ├─ items[:3] + ├─ total_count=真实数量 + └─ has_more=(真实数量 > 3) + +GET /api/v1/starbook/items + │ + ▼ +GetAssetsByType ── previewLimit=0 ──► 同一组 builders + │ + ├─ items=全部匹配项 + └─ 不因首页规则截断 +``` + +## 文档说明 + +- **适用范围**:星册首页分组预览和“更多”入口。 +- **不包含**:前端布局重做、数据库查询级分页重构、新增缓存。 +- **前置版本**:`starbookService` 已删除,相关能力已迁移到 `assetService`。 +- **历史依据**:旧版 `starbookService` 使用 `HomePageSize = 3`,原创按等级截断并计算 `has_more`。 +- **目标读者**:后端开发、前端联调和测试人员。 + +## 1. 当前行为与根因 + +`StarbookContent.vue` 已正确消费以下字段: + +- 原创:`gradeItem.has_more` +- 典藏/活动:`group.has_more` + +问题位于 `assetService/service/asset_service.go`:三个分组构建器返回所有项目,并把 `HasMore` 固定为 `false`。因此前端不需要增加兜底逻辑,修复应落在 API 数据生产端。 + +## 2. 分组构建器接口 + +三个构建器增加 `previewLimit int` 参数: + +```go +buildRegularGroupForAssets(..., previewLimit int) *pb.AssetGroup +buildCollectionGroupForAssets(..., previewLimit int) *pb.AssetGroup +buildActivityGroupForAssets(..., previewLimit int) *pb.AssetGroup +``` + +参数语义: + +- `previewLimit > 0`:先按 `models.Asset.LikeCount DESC, Asset.ID ASC` 排序,再最多保留指定数量;`has_more` 表示是否有项目被本次预览截断。 +- `previewLimit <= 0`:不截断,用于查看更多接口;分组和等级的 `has_more` 必须为 `false`,避免把“无限制模式”错误解释成仍有未返回项目。 + +定义首页常量: + +```go +const homePreviewLimit = 3 +``` + +不增加配置文件或环境变量;该值是已确认的固定产品规则。 + +## 3. 首页数据规则 + +### 3.1 原创藏品 + +原创按 `grade` 独立计算: + +1. 只统计能关联到真实 `Asset` 的有效注册记录。 +2. 每个等级使用关联到的 `models.Asset.LikeCount` 按点赞数降序排序;点赞数相同时按 `Asset.ID` 升序,不能使用可能漂移的 `AssetRegistry.LikeCount`。 +3. 每个等级最多返回 3 张。 +4. `GradeSection.TotalCount` 为该等级有效项目总数。 +5. `GradeSection.HasMore = previewLimit > 0 && TotalCount > previewLimit`。 +6. `AssetGroup.HasMore` 为任一等级 `HasMore=true`。 +7. 保持现有 grade 降序输出。 + +### 3.2 典收藏品 + +1. 构建有效项目列表。 +2. 使用关联到的 `models.Asset.LikeCount` 按点赞数降序排序;点赞数相同时按 `Asset.ID` 升序,不能使用 `AssetRegistry.LikeCount`。 +3. 首页最多返回前三张。 +4. `TotalCount` 为有效项目总数。 +5. `HasMore = previewLimit > 0 && TotalCount > previewLimit`。 + +### 3.3 活动藏品 + +规则与典藏一致:使用 `models.Asset.LikeCount` 按点赞数降序并以 `Asset.ID ASC` 稳定并列顺序,有效项目总数超过 3 时截断并返回 `HasMore=true`。 + +## 4. 查看更多数据规则 + +`GetAssetsByType` 调用构建器时传 `previewLimit=0`: + +- 不执行首页前三张截断。 +- 保留当前 type/category/grade 过滤逻辑。 +- 返回结果沿用首页的统一排序:`models.Asset.LikeCount DESC, Asset.ID ASC`。 +- 返回所有匹配分组项目,确保首页点击“更多”后能看到完整数据。 +- 本次不扩展数据库级分页;现有接口的分页字段行为不在此次修复范围内。 + +## 5. 前端行为 + +`frontend/pages/components/StarbookContent.vue` 不需要修改: + +- 原创继续使用 `v-if="gradeItem.has_more"`。 +- 典藏和活动继续使用 `v-if="group.has_more"`。 +- `processGroupsWithValidUrls` 的深拷贝会保留值为 `true` 的 `has_more` 字段。 + +验收行为: + +| 实际数量 | 首页显示数量 | “更多”按钮 | +|---:|---:|---| +| 0 | 0 | 不显示 | +| 1–3 | 1–3 | 不显示 | +| 4+ | 3 | 显示 | + +## 6. 测试设计 + +### 6.1 Service 单元测试 + +至少覆盖: + +1. 原创某等级 3 张:返回 3 张,等级和外层 `has_more=false`。 +2. 原创某等级 4 张:首页按 `Asset.LikeCount DESC, Asset.ID ASC` 返回前三张,`total_count=4`,等级和外层 `has_more=true`。 +3. 典藏 4 张:首页按 `Asset.LikeCount DESC, Asset.ID ASC` 返回前三张,`total_count=4`,`has_more=true`。 +4. 活动 4 张:首页按 `Asset.LikeCount DESC, Asset.ID ASC` 返回前三张,`total_count=4`,`has_more=true`。 +5. 点赞来源口径:故意让 `AssetRegistry.LikeCount` 与 `Asset.LikeCount` 相反,断言排序严格采用 `Asset.LikeCount`。 +6. 查看更多模式 4 张:按同一规则返回全部 4 张,分组/等级 `has_more=false`,不受首页上限影响。 +7. 边界值 `previewLimit=3`:数量为 0、1、3、4 时分别验证返回数量和 `has_more`,防止 off-by-one。 +8. 注册记录找不到对应资产时:无效记录不应制造错误的 `total_count` 或 `has_more`。 +9. 多个原创等级中仅一个等级超过 3 张:仅该等级 `has_more=true`,外层 `AssetGroup.HasMore=true`。 + +### 6.2 回归验证 + +- 运行 assetService 相关 service/provider 测试。 +- 运行 Starbook gateway controller 测试。 +- 编译 gateway 和 assetService。 +- 按 `dev.sh` 服务列表预编译全部后端服务。 +- 检查 `GetMyAssets` 与 `GetAssetsByType` 的调用方,确认二者分别传入 3 和 0。 + +## 7. 错误处理与兼容性 + +- 不改变 protobuf 字段或 REST 响应结构。 +- 不改变身份校验和 type/category/grade 过滤规则。 +- 空分组维持现有行为。 +- `has_more=false` 仍可能被 JSON 省略,前端将其视为假值,符合预期;只有需要按钮时必须返回 `true`。 + +## 8. 实施文件 + +预计修改: + +- `backend/services/assetService/service/asset_service.go` +- 对应的 assetService 测试文件(优先复用现有测试文件) + +预计不修改: + +- `frontend/pages/components/StarbookContent.vue` +- `backend/proto/asset.proto` +- 生成的 protobuf 文件 +- 数据库与 migration + +## 9. 非目标与后续项 + +本次明确不做: + +- 数据库查询层的真实分页与 limit/offset 下推。 +- 首页排序规则重构。 +- collection/activity 的分类结构重构。 +- 前端“更多”按钮样式调整。 + +如果单个用户藏品数量显著超过当前 `GetByOwner(..., 1000, 0)` 上限,应另立任务把首页预览下推到 repository 查询层;这不影响本次恢复既有按钮行为。