feat(asset): restore starbook home More button by Asset.LikeCount ordering

starbook 首页每个 grade/group 按 Asset.LikeCount DESC, Asset.ID ASC 取前三张,超三显示 has_more;
GetAssetsByType 不截断返回全部匹配项。包含:
- proto/asset.proto 声明 GetAssetsByType RPC + 消息,重新生成 .pb.go/.triple.go
- gateway starbook controller 改用标准 AssetService 客户端
- assetService: 三个 build*GroupForAssets 增加 previewLimit 参数
- 新增 sortAndLimitAssetItems 共享助手
- 新增 asset_service_group_test.go DB-free 单测
- 新增 TestStarbookHomePreviewAndMoreUseAssetLikeRanking 端到端回归
- frontend pages/starbook/items.vue 适配嵌套 groups/grades/items 响应,单 grade 竖向展示

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zerosaturation 2026-07-24 19:20:16 +08:00
parent 23e5a600ad
commit e079a6c2e2
10 changed files with 511 additions and 179 deletions

View File

@ -17,18 +17,14 @@ import (
// StarbookController 星册控制器
//
// ★ 批次 4.2 起,星册路由由 gateway → assetService 接管(原 starbookService 已删除)。
// client 类型使用 pbAsset.AssetServiceGetAssetsByTypeClient(扩展接口):
// - 嵌入 AssetService → 可调 GetMyAssets(home)
// - 自带 GetAssetsByType(items)
// 这样 gateway 端只需要一个 client 实例即可完成两个端点。
// GetMyAssets(home) 与 GetAssetsByType(items) 均由 asset.proto 生成的标准 AssetService client 提供。
type StarbookController struct {
assetClient pbAsset.AssetServiceGetAssetsByTypeClient
assetClient pbAsset.AssetService
}
// NewStarbookController 创建星册控制器
func NewStarbookController(assetClient *client.Client) (*StarbookController, error) {
// 使用扩展接口构造器(asset.pb.go 在 Task 1 已添加),保持与 AssetController 同一 Dubbo 客户端实例
assetService, err := pbAsset.NewAssetServiceGetAssetsByTypeClient(assetClient)
assetService, err := pbAsset.NewAssetService(assetClient)
if err != nil {
return nil, err
}
@ -152,4 +148,4 @@ func (ctrl *StarbookController) GetStarbookItems(c *gin.Context) {
}
response.Success(c, resp)
}
}

View File

@ -14,12 +14,12 @@ import (
)
// ============================================================
// fakeStarbookClient — 实现 pbAsset.AssetServiceGetAssetsByTypeClient
// fakeStarbookClient — 实现 pbAsset.AssetService
// 只 stub 关注的 GetMyAssets / GetAssetsByType 两个方法,
// 其余方法通过嵌入 nil 接口在调用时触发 panic(测试不会触发)
// ============================================================
type fakeStarbookClient struct {
pbAsset.AssetServiceGetAssetsByTypeClient // 嵌入 nil 接口
pbAsset.AssetService // 嵌入 nil 接口
// GetMyAssets 配置
homeResp *pbAsset.GetMyAssetsResponse
@ -269,4 +269,4 @@ func TestStarbookItems_MissingType(t *testing.T) {
// ============================================================
type errString string
func (e errString) Error() string { return string(e) }
func (e errString) Error() string { return string(e) }

View File

@ -7,11 +7,6 @@
package asset
import (
context "context"
client "dubbo.apache.org/dubbo-go/v3/client"
constant "dubbo.apache.org/dubbo-go/v3/common/constant"
triple_protocol "dubbo.apache.org/dubbo-go/v3/protocol/triple/triple_protocol"
server "dubbo.apache.org/dubbo-go/v3/server"
common "github.com/topfans/backend/pkg/proto/common"
_ "google.golang.org/genproto/googleapis/api/annotations"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
@ -3949,10 +3944,8 @@ func (x *TrackShareResponse) GetShareEventId() int64 {
return 0
}
// GetAssetsByTypeRequest filters a user's starbook assets by type.
//
// This type is appended manually because regenerating asset.triple.go is
// intentionally prohibited for the starbook recovery change.
// 按类型/分类/等级过滤查询星册藏品请求
// user_id/star_id 仅为兼容既有 wire contract服务端身份必须从可信 Dubbo attachments 读取。
type GetAssetsByTypeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
@ -4045,7 +4038,7 @@ func (x *GetAssetsByTypeRequest) GetPageSize() int32 {
return 0
}
// GetAssetsByTypeResponse returns the existing grouped AssetListData shape.
// 按类型/分类/等级过滤查询星册藏品响应
type GetAssetsByTypeResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Base *common.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"`
@ -4098,59 +4091,6 @@ func (x *GetAssetsByTypeResponse) GetData() *AssetListData {
return nil
}
// AssetServiceGetAssetsByTypeProcedure is the fully-qualified RPC procedure.
const AssetServiceGetAssetsByTypeProcedure = "/topfans.asset.AssetService/GetAssetsByType"
// AssetServiceGetAssetsByTypeClient extends the generated AssetService client
// without editing asset.triple.go.
type AssetServiceGetAssetsByTypeClient interface {
AssetService
GetAssetsByType(context.Context, *GetAssetsByTypeRequest, ...client.CallOption) (*GetAssetsByTypeResponse, error)
}
// AssetServiceGetAssetsByTypeHandler extends the generated provider contract
// without editing asset.triple.go.
type AssetServiceGetAssetsByTypeHandler interface {
AssetServiceHandler
GetAssetsByType(context.Context, *GetAssetsByTypeRequest) (*GetAssetsByTypeResponse, error)
}
// NewAssetServiceGetAssetsByTypeClient constructs a client exposing the new RPC.
func NewAssetServiceGetAssetsByTypeClient(cli *client.Client, opts ...client.ReferenceOption) (AssetServiceGetAssetsByTypeClient, error) {
conn, err := cli.DialWithInfo("topfans.asset.AssetService", &AssetService_ClientInfo, opts...)
if err != nil {
return nil, err
}
return &AssetServiceImpl{conn: conn}, nil
}
func (c *AssetServiceImpl) GetAssetsByType(ctx context.Context, req *GetAssetsByTypeRequest, opts ...client.CallOption) (*GetAssetsByTypeResponse, error) {
resp := new(GetAssetsByTypeResponse)
if err := c.conn.CallUnary(ctx, []interface{}{req}, resp, "GetAssetsByType", opts...); err != nil {
return nil, err
}
return resp, nil
}
func registerAssetServiceGetAssetsByType() {
AssetService_ClientInfo.MethodNames = append(AssetService_ClientInfo.MethodNames, "GetAssetsByType")
AssetService_ServiceInfo.Methods = append(AssetService_ServiceInfo.Methods, server.MethodInfo{
Name: "GetAssetsByType",
Type: constant.CallUnary,
ReqInitFunc: func() interface{} {
return new(GetAssetsByTypeRequest)
},
MethodFunc: func(ctx context.Context, args []interface{}, handler interface{}) (interface{}, error) {
req := args[0].(*GetAssetsByTypeRequest)
res, err := handler.(AssetServiceGetAssetsByTypeHandler).GetAssetsByType(ctx, req)
if err != nil {
return nil, err
}
return triple_protocol.NewResponse(res), nil
},
})
}
var File_asset_proto protoreflect.FileDescriptor
const file_asset_proto_rawDesc = "" +
@ -4479,13 +4419,18 @@ const file_asset_proto_rawDesc = "" +
"\x05extra\x18\a \x01(\v2\x17.google.protobuf.StructR\x05extra\"l\n" +
"\x12TrackShareResponse\x120\n" +
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12$\n" +
"\x0eshare_event_id\x18\x02 \x01(\x03R\fshareEventId" +
"\"\xc1\x01\x0a\x16GetAssetsByTypeRequest\x12\x17\x0a\x07user_id\x18\x01 \x01(\x03R\x06userId\x12\x17\x0a\x07star" +
"_id\x18\x02 \x01(\x03R\x06starId\x12\x12\x0a\x04type\x18\x03 \x01(\x09R\x04type\x12\x1a\x0a\x08category\x18\x04 \x01(\x09R\x08cat" +
"egory\x12\x14\x0a\x05grade\x18\x05 \x01(\x05R\x05grade\x12\x12\x0a\x04page\x18\x06 \x01(\x05R\x04page\x12\x1b\x0a\x09page_size" +
"\x18\x07 \x01(\x05R\x08pageSize\"}\x0a\x17GetAssetsByTypeResponse\x120\x0a\x04base\x18\x01 \x01(\x0b2\x1c." +
"topfans.common.BaseResponseR\x04base\x120\x0a\x04data\x18\x02 \x01(\x0b2\x1c.topfans.as" +
"set.AssetListDataR\x04data2\xf9\x13\n" +
"\x0eshare_event_id\x18\x02 \x01(\x03R\fshareEventId\"\xc1\x01\n" +
"\x16GetAssetsByTypeRequest\x12\x17\n" +
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x17\n" +
"\astar_id\x18\x02 \x01(\x03R\x06starId\x12\x12\n" +
"\x04type\x18\x03 \x01(\tR\x04type\x12\x1a\n" +
"\bcategory\x18\x04 \x01(\tR\bcategory\x12\x14\n" +
"\x05grade\x18\x05 \x01(\x05R\x05grade\x12\x12\n" +
"\x04page\x18\x06 \x01(\x05R\x04page\x12\x1b\n" +
"\tpage_size\x18\a \x01(\x05R\bpageSize\"}\n" +
"\x17GetAssetsByTypeResponse\x120\n" +
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x120\n" +
"\x04data\x18\x02 \x01(\v2\x1c.topfans.asset.AssetListDataR\x04data2\xf9\x13\n" +
"\fAssetService\x12Z\n" +
"\rInitMintOrder\x12#.topfans.asset.InitMintOrderRequest\x1a$.topfans.asset.InitMintOrderResponse\x12\x94\x01\n" +
"\x12PreCreateMintOrder\x12(.topfans.asset.PreCreateMintOrderRequest\x1a).topfans.asset.PreCreateMintOrderResponse\")\x82\xd3\xe4\x93\x02#:\x01*\"\x1e/api/v1/assets/mints/precreate\x12\x81\x01\n" +
@ -4509,9 +4454,8 @@ const file_asset_proto_rawDesc = "" +
"\x13UnbindAssetMaterial\x12).topfans.asset.UnbindAssetMaterialRequest\x1a*.topfans.asset.UnbindAssetMaterialResponse\x12]\n" +
"\x0eGetAssetQrcode\x12$.topfans.asset.GetAssetQrcodeRequest\x1a%.topfans.asset.GetAssetQrcodeResponse\x12Q\n" +
"\n" +
"TrackShare\x12 .topfans.asset.TrackShareRequest\x1a!.topfans.asset.TrackShareResponse" +
"\x12`\x0a\x0fGetAssetsByType\x12%.topfans.asset.GetAssetsByTypeRequest\x1a&" +
".topfans.asset.GetAssetsByTypeResponseB2Z0github.com/topfans/backend/pkg/proto/asset;assetb\x06proto3"
"TrackShare\x12 .topfans.asset.TrackShareRequest\x1a!.topfans.asset.TrackShareResponse\x12`\n" +
"\x0fGetAssetsByType\x12%.topfans.asset.GetAssetsByTypeRequest\x1a&.topfans.asset.GetAssetsByTypeResponseB2Z0github.com/topfans/backend/pkg/proto/asset;assetb\x06proto3"
var (
file_asset_proto_rawDescOnce sync.Once
@ -4685,10 +4629,7 @@ var file_asset_proto_depIdxs = []int32{
0, // [0:43] is the sub-list for field type_name
}
func init() {
file_asset_proto_init()
registerAssetServiceGetAssetsByType()
}
func init() { file_asset_proto_init() }
func file_asset_proto_init() {
if File_asset_proto != nil {
return

View File

@ -80,6 +80,8 @@ const (
AssetServiceGetAssetQrcodeProcedure = "/topfans.asset.AssetService/GetAssetQrcode"
// AssetServiceTrackShareProcedure is the fully-qualified name of the AssetService's TrackShare RPC.
AssetServiceTrackShareProcedure = "/topfans.asset.AssetService/TrackShare"
// AssetServiceGetAssetsByTypeProcedure is the fully-qualified name of the AssetService's GetAssetsByType RPC.
AssetServiceGetAssetsByTypeProcedure = "/topfans.asset.AssetService/GetAssetsByType"
)
var (
@ -110,6 +112,7 @@ type AssetService interface {
UnbindAssetMaterial(ctx context.Context, req *UnbindAssetMaterialRequest, opts ...client.CallOption) (*UnbindAssetMaterialResponse, error)
GetAssetQrcode(ctx context.Context, req *GetAssetQrcodeRequest, opts ...client.CallOption) (*GetAssetQrcodeResponse, error)
TrackShare(ctx context.Context, req *TrackShareRequest, opts ...client.CallOption) (*TrackShareResponse, error)
GetAssetsByType(ctx context.Context, req *GetAssetsByTypeRequest, opts ...client.CallOption) (*GetAssetsByTypeResponse, error)
}
// NewAssetService constructs a client for the asset.AssetService service.
@ -308,9 +311,17 @@ func (c *AssetServiceImpl) TrackShare(ctx context.Context, req *TrackShareReques
return resp, nil
}
func (c *AssetServiceImpl) GetAssetsByType(ctx context.Context, req *GetAssetsByTypeRequest, opts ...client.CallOption) (*GetAssetsByTypeResponse, error) {
resp := new(GetAssetsByTypeResponse)
if err := c.conn.CallUnary(ctx, []interface{}{req}, resp, "GetAssetsByType", opts...); err != nil {
return nil, err
}
return resp, nil
}
var AssetService_ClientInfo = client.ClientInfo{
InterfaceName: "topfans.asset.AssetService",
MethodNames: []string{"InitMintOrder", "PreCreateMintOrder", "CreateMintOrder", "EstimateMintCost", "GetMyAssets", "GetAsset", "GetAssetStatus", "GetMintOrder", "CancelMintOrder", "GetAssetForRPC", "LikeAsset", "UnlikeAsset", "CheckAssetLike", "GetAssetLikes", "ClearAssetLikeRecords", "UploadMaterial", "BindAssetMaterials", "GetAssetMaterials", "UpdateMaterialLayerOrder", "UnbindAssetMaterial", "GetAssetQrcode", "TrackShare"},
MethodNames: []string{"InitMintOrder", "PreCreateMintOrder", "CreateMintOrder", "EstimateMintCost", "GetMyAssets", "GetAsset", "GetAssetStatus", "GetMintOrder", "CancelMintOrder", "GetAssetForRPC", "LikeAsset", "UnlikeAsset", "CheckAssetLike", "GetAssetLikes", "ClearAssetLikeRecords", "UploadMaterial", "BindAssetMaterials", "GetAssetMaterials", "UpdateMaterialLayerOrder", "UnbindAssetMaterial", "GetAssetQrcode", "TrackShare", "GetAssetsByType"},
ConnectionInjectFunc: func(dubboCliRaw interface{}, conn *client.Connection) {
dubboCli := dubboCliRaw.(*AssetServiceImpl)
dubboCli.conn = conn
@ -341,6 +352,7 @@ type AssetServiceHandler interface {
UnbindAssetMaterial(context.Context, *UnbindAssetMaterialRequest) (*UnbindAssetMaterialResponse, error)
GetAssetQrcode(context.Context, *GetAssetQrcodeRequest) (*GetAssetQrcodeResponse, error)
TrackShare(context.Context, *TrackShareRequest) (*TrackShareResponse, error)
GetAssetsByType(context.Context, *GetAssetsByTypeRequest) (*GetAssetsByTypeResponse, error)
}
func RegisterAssetServiceHandler(srv *server.Server, hdlr AssetServiceHandler, opts ...server.ServiceOption) error {
@ -685,5 +697,20 @@ var AssetService_ServiceInfo = server.ServiceInfo{
return triple_protocol.NewResponse(res), nil
},
},
{
Name: "GetAssetsByType",
Type: constant.CallUnary,
ReqInitFunc: func() interface{} {
return new(GetAssetsByTypeRequest)
},
MethodFunc: func(ctx context.Context, args []interface{}, handler interface{}) (interface{}, error) {
req := args[0].(*GetAssetsByTypeRequest)
res, err := handler.(AssetServiceHandler).GetAssetsByType(ctx, req)
if err != nil {
return nil, err
}
return triple_protocol.NewResponse(res), nil
},
},
},
}

View File

@ -532,6 +532,9 @@ service AssetService {
rpc GetAssetQrcode(GetAssetQrcodeRequest) returns (GetAssetQrcodeResponse);
// (spec § 3.5)
rpc TrackShare(TrackShareRequest) returns (TrackShareResponse);
// // RPC gateway REST
rpc GetAssetsByType(GetAssetsByTypeRequest) returns (GetAssetsByTypeResponse);
}
// RPCGallery Service调用
@ -579,3 +582,21 @@ message TrackShareResponse {
topfans.common.BaseResponse base = 1;
int64 share_event_id = 2; // ID
}
// //
// user_id/star_id wire contract Dubbo attachments
message GetAssetsByTypeRequest {
int64 user_id = 1;
int64 star_id = 2;
string type = 3;
string category = 4;
int32 grade = 5;
int32 page = 6;
int32 page_size = 7;
}
// //
message GetAssetsByTypeResponse {
topfans.common.BaseResponse base = 1;
AssetListData data = 2;
}

View File

@ -6,10 +6,10 @@ import (
"time"
"dubbo.apache.org/dubbo-go/v3/common/constant"
"github.com/topfans/backend/pkg/authctx"
appErrors "github.com/topfans/backend/pkg/errors"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/models"
"github.com/topfans/backend/pkg/authctx"
pb "github.com/topfans/backend/pkg/proto/asset"
pbCommon "github.com/topfans/backend/pkg/proto/common"
"github.com/topfans/backend/services/assetService/service"
@ -39,16 +39,9 @@ type AssetProvider struct {
materialService *service.MaterialService
}
// 确保 AssetProvider 实现了 AssetServiceHandler 接口
// 确保 AssetProvider 实现了 AssetServiceHandler 接口(包含 GetAssetsByType
var _ pb.AssetServiceHandler = (*AssetProvider)(nil)
// 确保 AssetProvider 同时满足扩展 handler(Task 1 在 asset.pb.go 里通过 init()
// 把 GetAssetsByType 追加进了共享的 AssetService_ServiceInfo.Methods,
// 其 dispatch 会把注册的 handler 断言为 AssetServiceGetAssetsByTypeHandler。
// 因此 main.go 无需改动:现有 RegisterAssetServiceHandler(srv, assetProvider)
// 已经把本方法一并路由,只要 *AssetProvider 实现了它)。
var _ pb.AssetServiceGetAssetsByTypeHandler = (*AssetProvider)(nil)
// NewAssetProvider 创建资产服务Provider实例
func NewAssetProvider(assetService service.AssetService, mintService service.MintService, assetLikeService *service.AssetLikeService, materialService *service.MaterialService) *AssetProvider {
return &AssetProvider{

View File

@ -2,6 +2,7 @@ package provider
import (
"context"
"fmt"
"os"
"strconv"
"testing"
@ -145,6 +146,75 @@ func sbSeed(t *testing.T, db *gorm.DB) {
t.Cleanup(func() { sbCleanup(t, db) })
}
// sbAddRankedCollectionAssets 在 sbSeed 基础上追加一组带 like_count 梯度的
// 典藏藏品(手办分类),用于断言 hometop-3与 More全部的 LikeCount 排序契约。
// - collection_xsbSeed 写入) : like_count = 5
// - collection_ranked_0/1/2追加: like_count = 20/40/30
//
// 预期 home(limit=3): [40, 30, 20];预期 More(limit=0): [40, 30, 20, 5]。
// 注意Asset 记录不显式指定 id交由 PostgreSQL autoIncrement 分配,避免触 CLAUDE.md
// 序列同步规则;新的 AssetRegistry 记录只引用 autoIncrement 出来的 AssetID。
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)
}
}
// requireGroupByType 在 AssetListData.Groups 中按 type 查找目标分组,找不到直接 FailNow。
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
}
// itemLikeCounts 提取分组内 AssetItem 的 LikeCount 序列,便于断言排序契约。
func itemLikeCounts(items []*pb.AssetItem) []int32 {
counts := make([]int32, 0, len(items))
for _, item := range items {
counts = append(counts, item.LikeCount)
}
return counts
}
// sbProvider 组装真实 service真实 repo + test DB无关依赖传 nil。
func sbProvider(db *gorm.DB) *AssetProvider {
assetRepo := repository.NewAssetRepository(db)
@ -230,3 +300,34 @@ func TestGetAssetsByType_NoIdentity(t *testing.T) {
assert.Equal(t, uint32(codes.Unauthenticated), resp.Base.Code)
}
}
// TestStarbookHomePreviewAndMoreUseAssetLikeRanking 回归 home(limit=3) 与 More(limit=0)
// 的排序与截断契约:两个入口都应按 Asset.LikeCount DESC, Asset.ID ASC 排序,
// home 只返 top-3HasMore=trueMore 返回全部 4 条HasMore=false
// 排序稳定性通过 like_count 全相同时的 ID 次序隐式覆盖。
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)
}

View File

@ -16,10 +16,13 @@ import (
"github.com/topfans/backend/services/assetService/client"
"github.com/topfans/backend/services/assetService/repository"
"go.uber.org/zap"
"gorm.io/gorm"
"google.golang.org/grpc/codes"
"gorm.io/gorm"
)
// homePreviewLimit 首页每个等级分组的预览上限
const homePreviewLimit = 3
// AssetService 资产服务接口
type AssetService interface {
// GetMyAssets 获取我的藏品列表
@ -161,7 +164,7 @@ func (s *assetService) GetMyAssets(req *pb.GetMyAssetsRequest, userID, starID in
// 处理原创藏品 (regular)
if regs, ok := typeGroups[models.AssetTypeRegular]; ok {
group := s.buildRegularGroupForAssets(allAssets, registryMap, regs)
group := s.buildRegularGroupForAssets(allAssets, registryMap, regs, homePreviewLimit)
if group != nil {
groups = append(groups, group)
}
@ -169,7 +172,7 @@ func (s *assetService) GetMyAssets(req *pb.GetMyAssetsRequest, userID, starID in
// 处理典藏藏品 (collection)
if regs, ok := typeGroups[models.AssetTypeCollection]; ok {
group := s.buildCollectionGroupForAssets(allAssets, registryMap, regs)
group := s.buildCollectionGroupForAssets(allAssets, registryMap, regs, homePreviewLimit)
if group != nil {
groups = append(groups, group)
}
@ -177,7 +180,7 @@ func (s *assetService) GetMyAssets(req *pb.GetMyAssetsRequest, userID, starID in
// 处理活动藏品 (activity)
if regs, ok := typeGroups[models.AssetTypeActivity]; ok {
group := s.buildActivityGroupForAssets(allAssets, registryMap, regs)
group := s.buildActivityGroupForAssets(allAssets, registryMap, regs, homePreviewLimit)
if group != nil {
groups = append(groups, group)
}
@ -228,9 +231,9 @@ func (s *assetService) GetMyAssets(req *pb.GetMyAssetsRequest, userID, starID in
// - req.Type 非空时只保留该类型(regular/collection/activity)
// - req.Grade >0 时按 registry.grade 过滤(仅 regular 类型有效,其它类型 grade 恒为 nil 会被过滤空)
// - req.Category 非空时按逻辑分类过滤:
// * regular -> 恒为 "castlove"
// * collection -> registry.collection_category
// * activity -> registry.activity_type
// - regular -> 恒为 "castlove"
// - collection -> registry.collection_category
// - activity -> registry.activity_type
//
// 返回结构与 GetMyAssets 一致(AssetListData 分组),便于 gateway 复用旧 starbook 契约。
func (s *assetService) GetAssetsByType(req *pb.GetAssetsByTypeRequest, userID, starID int64) (*pb.GetAssetsByTypeResponse, error) {
@ -309,17 +312,17 @@ func (s *assetService) GetAssetsByType(req *pb.GetAssetsByTypeRequest, userID, s
groups := make([]*pb.AssetGroup, 0)
if regs, ok := typeGroups[models.AssetTypeRegular]; ok {
if group := s.buildRegularGroupForAssets(allAssets, registryMap, regs); group != nil {
if group := s.buildRegularGroupForAssets(allAssets, registryMap, regs, 0); group != nil {
groups = append(groups, group)
}
}
if regs, ok := typeGroups[models.AssetTypeCollection]; ok {
if group := s.buildCollectionGroupForAssets(allAssets, registryMap, regs); group != nil {
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); group != nil {
if group := s.buildActivityGroupForAssets(allAssets, registryMap, regs, 0); group != nil {
groups = append(groups, group)
}
}
@ -368,15 +371,29 @@ func registryLogicalCategory(reg *models.AssetRegistry) string {
return ""
}
// 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
}
// buildRegularGroupForAssets 构建原创藏品分组
func (s *assetService) buildRegularGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry) *pb.AssetGroup {
// 创建 assetID -> asset 映射
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
}
// 按 grade 分组
gradeGroups := make(map[int32][]*models.AssetRegistry)
for _, reg := range registries {
if reg.Grade != nil {
@ -384,17 +401,15 @@ func (s *assetService) buildRegularGroupForAssets(allAssets []*models.Asset, reg
}
}
// 构建 GradeSection
grades := make([]*pb.GradeSection, 0)
for grade, regs := range gradeGroups {
// 构建 items返回所有不限制数量
items := make([]*pb.AssetItem, 0)
items := make([]*pb.AssetItem, 0, len(regs))
for _, reg := range regs {
asset := assetMap[reg.AssetID]
if asset == nil {
continue
}
item := &pb.AssetItem{
items = append(items, &pb.AssetItem{
AssetId: asset.ID,
Name: asset.Name,
CoverUrlSigned: asset.CoverURL,
@ -403,28 +418,28 @@ func (s *assetService) buildRegularGroupForAssets(allAssets []*models.Asset, reg
Category: "castlove",
Grade: grade,
DisplayStatus: reg.DisplayStatus,
}
items = append(items, item)
})
}
gradeSection := &pb.GradeSection{
totalCount := int32(len(items))
items, hasMore := sortAndLimitAssetItems(items, previewLimit)
grades = append(grades, &pb.GradeSection{
Grade: grade,
Items: items,
TotalCount: int32(len(regs)),
HasMore: false, // 返回所有,不限制
}
grades = append(grades, gradeSection)
TotalCount: totalCount,
HasMore: hasMore,
})
}
// 按 grade 降序排序
sort.Slice(grades, func(i, j int) bool {
return grades[i].Grade > grades[j].Grade
})
// 计算 total_count
totalCount := int32(0)
for _, g := range grades {
totalCount += g.TotalCount
hasMore := false
for _, grade := range grades {
totalCount += grade.TotalCount
hasMore = hasMore || grade.HasMore
}
return &pb.AssetGroup{
@ -433,20 +448,18 @@ func (s *assetService) buildRegularGroupForAssets(allAssets []*models.Asset, reg
CategoryName: "原创",
Grades: grades,
TotalCount: totalCount,
HasMore: false,
HasMore: hasMore,
}
}
// buildCollectionGroupForAssets 构建典藏藏品分组
func (s *assetService) buildCollectionGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry) *pb.AssetGroup {
// 创建 assetID -> asset 映射
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
items := make([]*pb.AssetItem, 0)
items := make([]*pb.AssetItem, 0, len(registries))
for _, reg := range registries {
asset := assetMap[reg.AssetID]
if asset == nil {
@ -456,7 +469,7 @@ func (s *assetService) buildCollectionGroupForAssets(allAssets []*models.Asset,
if reg.CollectionCategory != nil {
category = *reg.CollectionCategory
}
item := &pb.AssetItem{
items = append(items, &pb.AssetItem{
AssetId: asset.ID,
Name: asset.Name,
CoverUrlSigned: asset.CoverURL,
@ -465,30 +478,29 @@ func (s *assetService) buildCollectionGroupForAssets(allAssets []*models.Asset,
Category: category,
Grade: 0,
DisplayStatus: reg.DisplayStatus,
}
items = append(items, item)
})
}
totalCount := int32(len(items))
items, hasMore := sortAndLimitAssetItems(items, previewLimit)
return &pb.AssetGroup{
Type: models.AssetTypeCollection,
Category: "",
CategoryName: "典藏",
Items: items,
TotalCount: int32(len(registries)),
HasMore: false,
TotalCount: totalCount,
HasMore: hasMore,
}
}
// buildActivityGroupForAssets 构建活动藏品分组
func (s *assetService) buildActivityGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry) *pb.AssetGroup {
// 创建 assetID -> asset 映射
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
items := make([]*pb.AssetItem, 0)
items := make([]*pb.AssetItem, 0, len(registries))
for _, reg := range registries {
asset := assetMap[reg.AssetID]
if asset == nil {
@ -498,7 +510,7 @@ func (s *assetService) buildActivityGroupForAssets(allAssets []*models.Asset, re
if reg.ActivityType != nil {
activityType = *reg.ActivityType
}
item := &pb.AssetItem{
items = append(items, &pb.AssetItem{
AssetId: asset.ID,
Name: asset.Name,
CoverUrlSigned: asset.CoverURL,
@ -507,17 +519,18 @@ func (s *assetService) buildActivityGroupForAssets(allAssets []*models.Asset, re
Category: activityType,
Grade: 0,
DisplayStatus: reg.DisplayStatus,
}
items = append(items, item)
})
}
totalCount := int32(len(items))
items, hasMore := sortAndLimitAssetItems(items, previewLimit)
return &pb.AssetGroup{
Type: models.AssetTypeActivity,
Category: "",
CategoryName: "活动",
Items: items,
TotalCount: int32(len(registries)),
HasMore: false,
TotalCount: totalCount,
HasMore: hasMore,
}
}

View File

@ -0,0 +1,110 @@
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)
}
func ptrString(value string) *string { return &value }
func TestBuildCollectionGroupForAssets_PreviewAndUnlimited(t *testing.T) {
registries := []*models.AssetRegistry{
{AssetID: 1, AssetType: models.AssetTypeCollection, CollectionCategory: ptrString("cards")},
{AssetID: 2, AssetType: models.AssetTypeCollection, CollectionCategory: ptrString("cards")},
{AssetID: 3, AssetType: models.AssetTypeCollection, CollectionCategory: ptrString("cards")},
{AssetID: 4, AssetType: models.AssetTypeCollection, CollectionCategory: ptrString("cards")},
{AssetID: 999, AssetType: models.AssetTypeCollection, CollectionCategory: ptrString("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: ptrString("offline")},
{AssetID: 2, AssetType: models.AssetTypeActivity, ActivityType: ptrString("offline")},
{AssetID: 3, AssetType: models.AssetTypeActivity, ActivityType: ptrString("offline")},
{AssetID: 4, AssetType: models.AssetTypeActivity, ActivityType: ptrString("offline")},
{AssetID: 999, AssetType: models.AssetTypeActivity, ActivityType: ptrString("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)
}

View File

@ -15,29 +15,70 @@
<!-- 藏品网格 -->
<view v-else class="nft-grid-container">
<view
v-for="item in items"
:key="item.asset_id"
class="nft-grid-item"
@click="handleCardClick(item)"
>
<image
class="nft-cover"
:class="{ 'nft-image-displayed': item.display_status === 1 }"
:src="item.coverUrl || item.cover_url_signed"
mode="aspectFill"
/>
<view v-if="item.display_status === 1" class="status-overlay">
<text class="status-text-center">已展示</text>
<!-- 原创藏品: grade 分组展示 -->
<template v-if="isRegular">
<view v-for="gradeSection in gradeSections" :key="gradeSection.grade" class="grade-block">
<view class="grade-block-header">
<text class="grade-block-title">{{ formatGrade(gradeSection.grade) }}</text>
</view>
<scroll-view
scroll-y
class="image-scroll-area"
:show-scrollbar="false"
:enhanced="true"
:bounces="true"
:scroll-animation-duration="300"
:enable-flex="true"
>
<view class="nft-grid-row">
<view
v-for="item in gradeSection.items"
:key="item.asset_id"
class="nft-grid-item"
@click="handleCardClick(item)"
>
<image
class="nft-cover"
:class="{ 'nft-image-displayed': item.display_status === 1 }"
:src="item.coverUrl || item.cover_url_signed"
mode="aspectFill"
/>
<view v-if="item.display_status === 1" class="status-overlay">
<text class="status-text-center">已展示</text>
</view>
<view class="card-rate-badge-overlay">
<image class="heart-icon" src="/static/icon/heart-icon.png" mode="aspectFit"></image>
<text class="card-rate-text">{{ item.like_count || 0 }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
<view class="card-rate-badge-overlay">
<image class="heart-icon" src="/static/icon/heart-icon.png" mode="aspectFit"></image>
<text class="card-rate-text">{{ item.like_count || 0 }}</text>
</template>
<!-- 典藏/活动藏品:平铺展示 -->
<template v-else>
<view
v-for="item in items"
:key="item.asset_id"
class="nft-grid-item"
@click="handleCardClick(item)"
>
<image
class="nft-cover"
:class="{ 'nft-image-displayed': item.display_status === 1 }"
:src="item.coverUrl || item.cover_url_signed"
mode="aspectFill"
/>
<view v-if="item.display_status === 1" class="status-overlay">
<text class="status-text-center">已展示</text>
</view>
<view class="card-rate-badge-overlay">
<image class="heart-icon" src="/static/icon/heart-icon.png" mode="aspectFit"></image>
<text class="card-rate-text">{{ item.like_count || 0 }}</text>
</view>
</view>
<!-- <view class="nft-info">
<text class="nft-name">{{ item.name }}</text>
</view> -->
</view>
</template>
</view>
<!-- 分页提示 -->
@ -82,6 +123,9 @@ const total = ref(0);
//
const items = ref([]);
// (,)
const gradeSections = ref([]);
//
const loading = ref(false);
@ -108,12 +152,15 @@ const pageTitle = computed(() => {
//
const hasData = computed(() => {
return items.value.length > 0;
return items.value.length > 0 || gradeSections.value.length > 0;
});
//
const isRegular = computed(() => type.value === 'regular');
//
const hasMore = computed(() => {
return items.value.length < total.value;
return groupHasMore.value;
});
// grade
@ -133,18 +180,44 @@ const loadData = async (append = false) => {
page.value,
pageSize.value
);
if (response.code === 0 && response.data) {
const newItems = response.data.data.items || [];
// URL
for (const item of newItems) {
item.coverUrl = await getAssetCoverRealUrl(item.cover_url_signed || '');
}
if (append) {
items.value = [...items.value, ...newItems];
if (response.code === 0 && response.data && response.data.data) {
const group = findGroupByType(response.data.data.groups, type.value);
if (group) {
total.value = group.total_count || 0;
groupHasMore.value = Boolean(group.has_more);
const flatItems = flattenGroupItems(group);
// URL
for (const item of flatItems) {
item.coverUrl = await getAssetCoverRealUrl(item.cover_url_signed || '');
}
if (type.value === 'regular') {
const sections = (group.grades || []).map((gradeSection) => ({
grade: gradeSection.grade,
items: (gradeSection.items || []).map(hydrateItem),
}));
if (append) {
gradeSections.value = [...gradeSections.value, ...sections];
} else {
gradeSections.value = sections;
}
items.value = gradeSections.value.flatMap((section) => section.items);
} else {
const newItems = flatItems.map(hydrateItem);
if (append) {
items.value = [...items.value, ...newItems];
} else {
items.value = newItems;
}
gradeSections.value = [];
}
} else {
items.value = newItems;
if (!append) {
items.value = [];
gradeSections.value = [];
total.value = 0;
}
}
total.value = response.data.total || 0;
}
} catch (error) {
console.error('获取藏品列表失败:', error);
@ -158,6 +231,25 @@ const loadData = async (append = false) => {
}
};
// findGroupByType ( group,)
function findGroupByType(groups, assetType) {
if (!Array.isArray(groups)) return null;
return groups.find((group) => group && group.type === assetType) || null;
}
// flattenGroupItems group items ( grades, flat items)
function flattenGroupItems(group) {
if (Array.isArray(group.grades) && group.grades.length > 0) {
return group.grades.flatMap((gradeSection) => gradeSection.items || []);
}
return Array.isArray(group.items) ? group.items : [];
}
// hydrateItem item key ,便 v-for 使
function hydrateItem(item) {
return item || {};
}
//
const loadMore = () => {
if (!hasMore.value || loading.value) return;
@ -165,6 +257,9 @@ const loadMore = () => {
loadData(true);
};
//
const groupHasMore = ref(false);
//
const handleCardClick = (item) => {
if (item.asset_id) {
@ -186,7 +281,11 @@ onMounted(() => {
type.value = options.type || 'regular';
category.value = options.category || 'castlove';
grade.value = options.grade ? parseInt(options.grade) : null;
if (type.value === 'regular' && options.grade) {
grade.value = parseInt(options.grade);
} else {
grade.value = null;
}
page.value = parseInt(options.page) || 1;
//
@ -239,6 +338,37 @@ onMounted(() => {
box-sizing: border-box;
}
/* 原创等级分块 */
.grade-block {
width: 100%;
margin-bottom: 24rpx;
}
.grade-block-header {
display: flex;
align-items: center;
padding: 0 0 16rpx;
}
.grade-block-title {
color: rgba(255, 255, 255, 0.85);
font-size: 28rpx;
font-weight: 600;
}
.nft-grid-row {
display: flex;
flex-wrap: wrap;
}
/* 图片区滚动容器(不改变内部 3 列 grid 布局) */
.image-scroll-area {
max-height: calc(100vh - 320rpx);
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.nft-grid-item {
position: relative;
display: flex;