docs(starbook): add has_more design spec and implementation plan

记录星册首页 More 按钮恢复的方案说明与 TDD 实施计划,作为本次特性 commit e079a6c 的设计依据。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zerosaturation 2026-07-24 19:20:32 +08:00
parent e079a6c2e2
commit 383b5e042e
2 changed files with 970 additions and 0 deletions

View File

@ -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`.

View File

@ -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 | 不显示 |
| 13 | 13 | 不显示 |
| 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 查询层;这不影响本次恢复既有按钮行为。