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:
parent
e079a6c2e2
commit
383b5e042e
766
docs/superpowers/plans/2026-07-24-starbook-home-has-more.md
Normal file
766
docs/superpowers/plans/2026-07-24-starbook-home-has-more.md
Normal 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`.
|
||||
@ -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 查询层;这不影响本次恢复既有按钮行为。
|
||||
Loading…
Reference in New Issue
Block a user