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:
parent
23e5a600ad
commit
e079a6c2e2
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
// 清除资产点赞记录请求(内部RPC,供Gallery 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;
|
||||
}
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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 梯度的
|
||||
// 典藏藏品(手办分类),用于断言 home(top-3)与 More(全部)的 LikeCount 排序契约。
|
||||
// - collection_x(sbSeed 写入) : 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-3(HasMore=true),More 返回全部 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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user