记录星册首页 More 按钮恢复的方案说明与 TDD 实施计划,作为本次特性 commit e079a6c 的设计依据。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
767 lines
28 KiB
Markdown
767 lines
28 KiB
Markdown
# 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`.
|