diff --git a/backend/dev.sh b/backend/dev.sh
index 86825c0..6c6531b 100755
--- a/backend/dev.sh
+++ b/backend/dev.sh
@@ -21,7 +21,7 @@ cleanup() {
fi
# 清理所有 PID 文件并杀服务进程
- for service in gateway activityService galleryService socialService assetService userService taskService; do
+ for service in gateway activityService galleryService socialService assetService userService taskService starbookService; do
pkill -9 -f "$service" 2>/dev/null || true
rm -f "/tmp/dev_sh_${service}.pid" "/tmp/dev_sh_${service}_restart" "/tmp/dev_sh_${service}.lock"
echo -e "${YELLOW} 🛑 $service 已停止${NC}"
@@ -242,7 +242,8 @@ start_watcher() {
--exclude='socialService$' \
--exclude='galleryService$' \
--exclude='activityService$' \
- --exclude='taskService$'
+ --exclude='taskService$' \
+ --exclude='starbookService$'
else
inotifywait -r -m -e modify,create,write "$watch_path" \
--exclude='\.git' \
@@ -254,7 +255,8 @@ start_watcher() {
--exclude='socialService$' \
--exclude='galleryService$' \
--exclude='activityService$' \
- --exclude='taskService$'
+ --exclude='taskService$' \
+ --exclude='starbookService$'
fi | while read event; do
# 时间戳防抖:每次事件更新标记文件
# Darwin 不支持 date +%s%N,使用 python 获取纳秒时间戳
@@ -307,13 +309,13 @@ echo ""
> /tmp/dev_sh_watchers.tmp
# 清理残留 PID 文件(上次非正常退出可能留下)
-for service in activityService galleryService socialService assetService userService taskService gateway; do
+for service in activityService galleryService socialService assetService userService taskService gateway starbookService; do
rm -f "/tmp/dev_sh_${service}.pid" "/tmp/dev_sh_${service}_restart"
done
# 停止现有服务(清理环境)
echo -e "${YELLOW}🛑 停止现有服务...${NC}"
-for service in gateway userService socialService assetService galleryService activityService taskService; do
+for service in gateway userService socialService assetService galleryService activityService taskService starbookService; do
pkill -9 -f "$service" 2>/dev/null || true
done
sleep 1
@@ -328,6 +330,7 @@ build_service "socialService" "services/socialService" "services/socialService
build_service "galleryService" "services/galleryService" "services/galleryService/galleryService"
build_service "activityService" "services/activityService" "services/activityService/activityService"
build_service "taskService" "services/taskService" "services/taskService/taskService"
+build_service "starbookService" "services/starbookService" "services/starbookService/starbookService"
cd "$SCRIPT_DIR"
# 启动所有服务
@@ -339,6 +342,7 @@ start_service "socialService" "services/socialService/socialService" 20002
start_service "galleryService" "services/galleryService/galleryService" 20004 1
start_service "activityService" "services/activityService/activityService" 20005 1
start_service "taskService" "services/taskService/taskService" 20006 1
+start_service "starbookService" "services/starbookService/starbookService" 20007 1
start_service "gateway" "gateway/gateway" 8080 0
# 启动所有文件监听器
@@ -351,6 +355,7 @@ start_watcher "socialService" "services/socialService" "services/socialService
start_watcher "galleryService" "services/galleryService" "services/galleryService/galleryService" 20004 1
start_watcher "activityService" "services/activityService" "services/activityService/activityService" 20005 1
start_watcher "taskService" "services/taskService" "services/taskService/taskService" 20006 1
+start_watcher "starbookService" "services/starbookService" "services/starbookService/starbookService" 20007 1
echo ""
echo -e "${GREEN}========================================${NC}"
@@ -366,6 +371,7 @@ echo " - Asset Service: tri://localhost:20003"
echo " - Gallery Service: tri://localhost:20004"
echo " - Activity Service: tri://localhost:20005"
echo " - Task Service: tri://localhost:20006"
+echo " - Starbook Service: tri://localhost:20007"
echo ""
echo -e "${YELLOW}按 Ctrl+C 停止所有服务${NC}"
echo ""
diff --git a/backend/gateway/config/config.go b/backend/gateway/config/config.go
index 6029082..1ce8819 100644
--- a/backend/gateway/config/config.go
+++ b/backend/gateway/config/config.go
@@ -22,12 +22,13 @@ type ServerConfig struct {
// DubboConfig Dubbo 服务配置
type DubboConfig struct {
- UserServiceURL string
- SocialServiceURL string
- AssetServiceURL string
- GalleryServiceURL string
- ActivityServiceURL string
- TaskServiceURL string
+ UserServiceURL string
+ SocialServiceURL string
+ AssetServiceURL string
+ GalleryServiceURL string
+ ActivityServiceURL string
+ TaskServiceURL string
+ StarbookServiceURL string
}
// JWTConfig JWT 配置
@@ -73,6 +74,7 @@ func Load() *Config {
GalleryServiceURL: getEnv("DUBBO_GALLERY_SERVICE_URL", "tri://127.0.0.1:20004"),
ActivityServiceURL: getEnv("DUBBO_ACTIVITY_SERVICE_URL", "tri://127.0.0.1:20005"),
TaskServiceURL: getEnv("DUBBO_TASK_SERVICE_URL", "tri://127.0.0.1:20006"),
+ StarbookServiceURL: getEnv("DUBBO_STARBOOK_SERVICE_URL", "tri://127.0.0.1:20007"),
},
JWT: JWTConfig{
Secret: getEnv("JWT_SECRET", "topfans-secret-key-please-change-in-production"),
diff --git a/backend/gateway/controller/asset_controller.go b/backend/gateway/controller/asset_controller.go
index a78ca4b..ae9b7b8 100644
--- a/backend/gateway/controller/asset_controller.go
+++ b/backend/gateway/controller/asset_controller.go
@@ -274,7 +274,7 @@ func (ctrl *AssetController) CreateMintOrder(c *gin.Context) {
Name: req.Name,
MaterialUrl: req.MaterialURL, // material_url 必填,cover_url 由后端 AI 生成
Description: req.Description,
- Rarity: req.Rarity,
+ Grade: req.Grade,
Tags: req.Tags,
MaterialType: req.MaterialType,
Info: req.Info,
diff --git a/backend/gateway/controller/starbook_controller.go b/backend/gateway/controller/starbook_controller.go
new file mode 100644
index 0000000..0778c1b
--- /dev/null
+++ b/backend/gateway/controller/starbook_controller.go
@@ -0,0 +1,157 @@
+package controller
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+
+ "dubbo.apache.org/dubbo-go/v3/client"
+ "dubbo.apache.org/dubbo-go/v3/common/constant"
+ "github.com/gin-gonic/gin"
+ "github.com/topfans/backend/gateway/pkg/response"
+ "github.com/topfans/backend/pkg/logger"
+ pb "github.com/topfans/backend/pkg/proto/starbook"
+ "go.uber.org/zap"
+)
+
+// StarbookController 星册控制器
+type StarbookController struct {
+ starbookService pb.StarbookService
+}
+
+// NewStarbookController 创建星册控制器
+func NewStarbookController(dubboClient *client.Client) (*StarbookController, error) {
+ // 创建 StarbookService 客户端
+ starbookService, err := pb.NewStarbookService(dubboClient)
+ if err != nil {
+ return nil, err
+ }
+
+ return &StarbookController{
+ starbookService: starbookService,
+ }, nil
+}
+
+// GetStarbookHome 获取星册首页
+// @Summary 获取星册首页
+// @Description 获取当前用户的星册首页数据,按类型和分组展示
+// @Tags starbook
+// @Accept json
+// @Produce json
+// @Security BearerAuth
+// @Success 200 {object} response.Response
+// @Router /api/v1/starbook/home [get]
+func (ctrl *StarbookController) GetStarbookHome(c *gin.Context) {
+ // 验证用户是否已登录(通过 AuthMiddleware 设置的上下文)
+ userID, exists := c.Get("user_id")
+ if !exists {
+ response.Error(c, http.StatusUnauthorized, "未授权")
+ return
+ }
+ starID, exists := c.Get("star_id")
+ if !exists {
+ response.Error(c, http.StatusUnauthorized, "未授权")
+ return
+ }
+
+ // 设置上下文和 Dubbo attachments
+ ctx := context.WithValue(c.Request.Context(), constant.AttachmentKey, map[string]interface{}{
+ "user_id": strconv.FormatInt(userID.(int64), 10),
+ "star_id": strconv.FormatInt(starID.(int64), 10),
+ })
+
+ logger.Logger.Debug("Calling GetStarbookHome",
+ zap.Int64("user_id", userID.(int64)),
+ zap.Int64("star_id", starID.(int64)),
+ )
+
+ // 调用星册服务(通过 Dubbo,user/star ID 从 context 的 attachments 中获取)
+ resp, err := ctrl.starbookService.GetStarbookHome(ctx, &pb.GetStarbookHomeRequest{})
+ if err != nil {
+ logger.Logger.Error("GetStarbookHome RPC failed",
+ zap.Error(err),
+ zap.Int64("user_id", userID.(int64)),
+ zap.Int64("star_id", starID.(int64)),
+ )
+ response.Error(c, http.StatusInternalServerError, "获取星册首页失败: "+err.Error())
+ return
+ }
+
+ response.Success(c, resp)
+}
+
+// GetStarbookItems 获取星册藏品列表
+// @Summary 获取星册藏品列表
+// @Description 获取指定分组的藏品列表,支持分页
+// @Tags starbook
+// @Accept json
+// @Produce json
+// @Security BearerAuth
+// @Param type query string true "资产类型: regular/collection/activity"
+// @Param category query string false "子分类,regular 时固定为 castlove"
+// @Param grade query int false "等级,仅 regular 类型有效"
+// @Param page query int false "页码,默认1"
+// @Param page_size query int false "每页数量,默认20"
+// @Success 200 {object} response.Response
+// @Router /api/v1/starbook/items [get]
+func (ctrl *StarbookController) GetStarbookItems(c *gin.Context) {
+ // 验证用户是否已登录
+ userID, exists := c.Get("user_id")
+ if !exists {
+ response.Error(c, http.StatusUnauthorized, "未授权")
+ return
+ }
+ starID, exists := c.Get("star_id")
+ if !exists {
+ response.Error(c, http.StatusUnauthorized, "未授权")
+ return
+ }
+
+ // 解析查询参数
+ assetType := c.Query("type")
+ if assetType == "" {
+ response.Error(c, http.StatusBadRequest, "type 参数不能为空")
+ return
+ }
+
+ category := c.DefaultQuery("category", "castlove")
+ grade, _ := strconv.Atoi(c.DefaultQuery("grade", "0"))
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
+
+ // 设置上下文和 Dubbo attachments
+ ctx := context.WithValue(c.Request.Context(), constant.AttachmentKey, map[string]interface{}{
+ "user_id": strconv.FormatInt(userID.(int64), 10),
+ "star_id": strconv.FormatInt(starID.(int64), 10),
+ })
+
+ // 构建请求
+ req := &pb.GetStarbookItemsRequest{
+ Type: assetType,
+ Category: category,
+ Grade: int32(grade),
+ Page: int32(page),
+ PageSize: int32(pageSize),
+ }
+
+ logger.Logger.Debug("Calling GetStarbookItems",
+ zap.Int64("user_id", userID.(int64)),
+ zap.Int64("star_id", starID.(int64)),
+ zap.String("type", assetType),
+ zap.Int32("page", int32(page)),
+ )
+
+ // 调用星册服务
+ resp, err := ctrl.starbookService.GetStarbookItems(ctx, req)
+ if err != nil {
+ logger.Logger.Error("GetStarbookItems RPC failed",
+ zap.Error(err),
+ zap.Int64("user_id", userID.(int64)),
+ zap.Int64("star_id", starID.(int64)),
+ )
+ response.Error(c, http.StatusInternalServerError, "获取星册藏品列表失败: "+err.Error())
+ return
+ }
+
+ response.Success(c, resp)
+}
diff --git a/backend/gateway/dto/asset_converter.go b/backend/gateway/dto/asset_converter.go
index 84260a6..9c06ce4 100644
--- a/backend/gateway/dto/asset_converter.go
+++ b/backend/gateway/dto/asset_converter.go
@@ -93,8 +93,8 @@ func ConvertAsset(pbAsset *pbAsset.Asset) AssetDTO {
if pbAsset.Description != "" {
dto.Description = pbAsset.Description
}
- if pbAsset.Rarity > 0 {
- dto.Rarity = pbAsset.Rarity
+ if pbAsset.Grade > 0 {
+ dto.Grade = pbAsset.Grade
}
if len(pbAsset.Tags) > 0 {
dto.Tags = pbAsset.Tags
diff --git a/backend/gateway/dto/asset_dto.go b/backend/gateway/dto/asset_dto.go
index 2436274..631ee96 100644
--- a/backend/gateway/dto/asset_dto.go
+++ b/backend/gateway/dto/asset_dto.go
@@ -8,7 +8,7 @@ type CreateMintOrderRequestDTO struct {
Name string `json:"name"` // 藏品名称(可选)
MaterialURL string `json:"material_url" binding:"required"` // 用户上传的素材URL(必填,前端已上传到OSS)
Description string `json:"description"` // 藏品描述(可选)
- Rarity int32 `json:"rarity"` // 稀有度(可选)
+ Grade int32 `json:"grade"` // 星册等级(可选)
Tags []string `json:"tags"` // 标签列表(可选)
MaterialType string `json:"material_type"` // 素材类型(可选)
Info string `json:"info" binding:"required"` // 藏品信息(必填)
@@ -67,7 +67,7 @@ type AssetDTO struct {
MaterialURL string `json:"material_url,omitempty"` // 用户上传的素材URL
MaterialURLSigned string `json:"material_url_signed,omitempty"` // 素材图预签名URL(自动生成)
Description string `json:"description,omitempty"` // 藏品描述(可选)
- Rarity int32 `json:"rarity,omitempty"` // 稀有度(可选)
+ Grade int32 `json:"grade,omitempty"` // 星册等级(可选)
Tags []string `json:"tags,omitempty"` // 标签数组(可选)
Visibility string `json:"visibility"` // 可见性:private, friends, public
Status int32 `json:"status"` // 状态:0=Pending, 1=Active
diff --git a/backend/gateway/main.go b/backend/gateway/main.go
index 5b1e138..bba3b26 100644
--- a/backend/gateway/main.go
+++ b/backend/gateway/main.go
@@ -60,6 +60,7 @@ func main() {
zap.String("gallery_service_url", cfg.Dubbo.GalleryServiceURL),
zap.String("activity_service_url", cfg.Dubbo.ActivityServiceURL),
zap.String("task_service_url", cfg.Dubbo.TaskServiceURL),
+ zap.String("starbook_service_url", cfg.Dubbo.StarbookServiceURL),
)
// 3. 设置 Gin 模式
@@ -122,9 +123,18 @@ func main() {
}
logger.Logger.Info("Task Service Dubbo client connected successfully")
+ // 4.7 StarbookService Client
+ starbookClient, err := client.NewClient(
+ client.WithClientURL(cfg.Dubbo.StarbookServiceURL),
+ )
+ if err != nil {
+ logger.Logger.Fatal("Failed to create Starbook Service Dubbo client", zap.Error(err))
+ }
+ logger.Logger.Info("Starbook Service Dubbo client connected successfully")
+
// 5. 设置路由
logger.Logger.Info("Setting up routes...")
- r, err := router.SetupRouter(userClient, socialClient, assetClient, galleryClient, activityClient, taskClient)
+ r, err := router.SetupRouter(userClient, socialClient, assetClient, galleryClient, activityClient, taskClient, starbookClient)
if err != nil {
logger.Logger.Fatal("Failed to setup router", zap.Error(err))
}
diff --git a/backend/gateway/router/router.go b/backend/gateway/router/router.go
index 1a364c6..9f54416 100644
--- a/backend/gateway/router/router.go
+++ b/backend/gateway/router/router.go
@@ -11,7 +11,7 @@ import (
)
// SetupRouter 设置路由
-func SetupRouter(userClient *client.Client, socialClient *client.Client, assetClient *client.Client, galleryClient *client.Client, activityClient *client.Client, taskClient *client.Client) (*gin.Engine, error) {
+func SetupRouter(userClient *client.Client, socialClient *client.Client, assetClient *client.Client, galleryClient *client.Client, activityClient *client.Client, taskClient *client.Client, starbookClient *client.Client) (*gin.Engine, error) {
r := gin.Default()
// 全局中间件
@@ -71,6 +71,11 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
return nil, err
}
+ starbookCtrl, err := controller.NewStarbookController(starbookClient)
+ if err != nil {
+ return nil, err
+ }
+
// API v1 路由组
v1 := r.Group("/api/v1")
{
@@ -239,6 +244,14 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
tasks.POST("/exhibition-revenue/claim", taskCtrl.ClaimExhibitionRevenue) // 领取单条展示收益
tasks.POST("/exhibition-revenue/claim-all", taskCtrl.ClaimAllExhibitionRevenue) // 一键领取所有展示收益
}
+
+ // 星册相关路由(需要认证)
+ starbook := v1.Group("/starbook")
+ starbook.Use(middleware.AuthMiddleware())
+ {
+ starbook.GET("/home", starbookCtrl.GetStarbookHome) // 获取星册首页
+ starbook.GET("/items", starbookCtrl.GetStarbookItems) // 获取星册藏品列表
+ }
}
return r, nil
diff --git a/backend/pkg/errors/errors.go b/backend/pkg/errors/errors.go
index 91c0957..b366b78 100644
--- a/backend/pkg/errors/errors.go
+++ b/backend/pkg/errors/errors.go
@@ -57,6 +57,12 @@ var (
// 活动服务相关错误
ErrActivityNotFound = errors.New("活动不存在")
ErrActivityItemNotFound = errors.New("活动道具不存在")
+
+ // 星册服务相关错误
+ ErrCollectionAssetNotFound = errors.New("典藏藏品不存在")
+ ErrActivityAssetNotFound = errors.New("活动藏品不存在")
+ ErrAssetRegistryNotFound = errors.New("资产索引不存在")
+ ErrInvalidAssetType = errors.New("无效的资产类型")
)
// ToStatusCode 将错误转换为Proto状态码
@@ -88,10 +94,10 @@ func ToStatusCode(err error) pb.StatusCode {
return pb.StatusCode_STATUS_BAD_REQUEST
case ErrAssetAccessDenied, ErrMintOrderAccessDenied:
return pb.StatusCode_STATUS_FORBIDDEN
- case ErrActivityNotFound, ErrActivityItemNotFound:
+ case ErrActivityNotFound, ErrActivityItemNotFound, ErrCollectionAssetNotFound, ErrActivityAssetNotFound, ErrAssetRegistryNotFound:
return pb.StatusCode_STATUS_NOT_FOUND
- case ErrInternalServer:
- return pb.StatusCode_STATUS_INTERNAL_ERROR
+ case ErrInvalidAssetType:
+ return pb.StatusCode_STATUS_BAD_REQUEST
default:
return pb.StatusCode_STATUS_INTERNAL_ERROR
}
diff --git a/backend/pkg/models/activity_asset.go b/backend/pkg/models/activity_asset.go
new file mode 100644
index 0000000..50d6822
--- /dev/null
+++ b/backend/pkg/models/activity_asset.go
@@ -0,0 +1,52 @@
+package models
+
+import (
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// ActivityAsset 活动藏品表模型
+type ActivityAsset struct {
+ ID int64 `gorm:"primaryKey;autoIncrement;column:id"`
+ AssetID int64 `gorm:"unique;not null;column:asset_id"`
+ OwnerUID int64 `gorm:"not null;index:idx_activity_owner;column:owner_uid"`
+ StarID int64 `gorm:"not null;index:idx_activity_star;column:star_id"`
+ ActivityID int64 `gorm:"not null;index:idx_activity_owner;column:activity_id"`
+ ActivityType string `gorm:"type:varchar(50);not null;index:idx_activity_type;column:activity_type"`
+ Name string `gorm:"type:varchar(100);not null;column:name"`
+ CoverURL string `gorm:"type:varchar(500);not null;column:cover_url"`
+ LikeCount int32 `gorm:"default:0;column:like_count"`
+ Status int32 `gorm:"default:0;column:status"` // 0=Pending, 1=Active
+ Metadata JSONB `gorm:"type:jsonb;column:metadata"`
+ CreatedAt int64 `gorm:"not null;column:created_at"`
+ UpdatedAt int64 `gorm:"not null;column:updated_at"`
+}
+
+// TableName 指定表名
+func (ActivityAsset) TableName() string {
+ return "activity_assets"
+}
+
+// BeforeCreate 创建前钩子
+func (a *ActivityAsset) BeforeCreate(tx *gorm.DB) error {
+ now := time.Now().UnixMilli()
+ a.CreatedAt = now
+ a.UpdatedAt = now
+ if a.Status == 0 {
+ a.Status = AssetStatusPending
+ }
+ return nil
+}
+
+// BeforeUpdate 更新前钩子
+func (a *ActivityAsset) BeforeUpdate(tx *gorm.DB) error {
+ a.UpdatedAt = time.Now().UnixMilli()
+ return nil
+}
+
+// 活动藏品状态常量
+const (
+ ActivityAssetStatusPending = 0 // 待处理
+ ActivityAssetStatusActive = 1 // 已激活
+)
diff --git a/backend/pkg/models/asset.go b/backend/pkg/models/asset.go
index 9faa055..46cc9e6 100644
--- a/backend/pkg/models/asset.go
+++ b/backend/pkg/models/asset.go
@@ -19,7 +19,7 @@ type Asset struct {
// 预留字段(后续功能扩展)
Description *string `gorm:"type:text;column:description"` // 藏品描述(预留)
- Rarity *int32 `gorm:"column:rarity"` // 稀有度(预留)
+ Grade *int32 `gorm:"column:grade"` // 星册等级(铸爱流程创建时默认为1)
Tags StringArray `gorm:"type:jsonb;column:tags"` // 标签(预留,JSON数组)
Visibility string `gorm:"type:varchar(20);default:'private';column:visibility"` // 可见性:private, friends, public(预留)
Info string `gorm:"type:text;column:info"` // 藏品信息(必填)
diff --git a/backend/pkg/models/asset_registry.go b/backend/pkg/models/asset_registry.go
new file mode 100644
index 0000000..e3aad81
--- /dev/null
+++ b/backend/pkg/models/asset_registry.go
@@ -0,0 +1,60 @@
+package models
+
+import (
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// AssetRegistry 资产统一索引表模型
+type AssetRegistry struct {
+ ID int64 `gorm:"primaryKey;autoIncrement;column:id"`
+ AssetID int64 `gorm:"not null;column:asset_id"`
+ AssetType string `gorm:"type:varchar(20);not null;column:asset_type"` // 'regular' | 'collection' | 'activity'
+ OwnerUID int64 `gorm:"not null;index:idx_registry_owner_star;column:owner_uid"`
+ StarID int64 `gorm:"not null;index:idx_registry_owner_star;column:star_id"`
+ // 普通藏品专属字段
+ Grade *int32 `gorm:"column:grade"` // 仅 regular 类型时有效
+ // 典藏专属字段
+ CollectionCategory *string `gorm:"type:varchar(50);column:collection_category"` // 仅 collection 类型时有效
+ // 活动专属字段
+ ActivityID *int64 `gorm:"column:activity_id"` // 仅 activity 类型时有效
+ ActivityType *string `gorm:"type:varchar(50);column:activity_type"` // 仅 activity 类型时有效
+ // 公共字段
+ Status int32 `gorm:"default:0;column:status"`
+ LikeCount int32 `gorm:"default:0;column:like_count"`
+ CreatedAt int64 `gorm:"not null;column:created_at"`
+ UpdatedAt int64 `gorm:"not null;column:updated_at"`
+}
+
+// TableName 指定表名
+func (AssetRegistry) TableName() string {
+ return "asset_registry"
+}
+
+// BeforeCreate 创建前钩子
+func (r *AssetRegistry) BeforeCreate(tx *gorm.DB) error {
+ now := time.Now().UnixMilli()
+ r.CreatedAt = now
+ r.UpdatedAt = now
+ return nil
+}
+
+// BeforeUpdate 更新前钩子
+func (r *AssetRegistry) BeforeUpdate(tx *gorm.DB) error {
+ r.UpdatedAt = time.Now().UnixMilli()
+ return nil
+}
+
+// 资产类型常量
+const (
+ AssetTypeRegular = "regular" // 普通藏品
+ AssetTypeCollection = "collection" // 典藏藏品
+ AssetTypeActivity = "activity" // 活动藏品
+)
+
+// AssetRegistry 状态常量
+const (
+ AssetRegistryStatusPending = 0 // 待处理
+ AssetRegistryStatusActive = 1 // 已激活
+)
diff --git a/backend/pkg/models/collection_asset.go b/backend/pkg/models/collection_asset.go
new file mode 100644
index 0000000..c5ca553
--- /dev/null
+++ b/backend/pkg/models/collection_asset.go
@@ -0,0 +1,69 @@
+package models
+
+import (
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// CollectionAsset 典藏藏品表模型
+type CollectionAsset struct {
+ ID int64 `gorm:"primaryKey;autoIncrement;column:id"`
+ AssetID int64 `gorm:"unique;not null;column:asset_id"`
+ OwnerUID int64 `gorm:"not null;index:idx_collection_owner_star;column:owner_uid"`
+ StarID int64 `gorm:"not null;index:idx_collection_owner_star;column:star_id"`
+ Name string `gorm:"type:varchar(100);not null;column:name"`
+ CoverURL string `gorm:"type:varchar(500);not null;column:cover_url"`
+ Category string `gorm:"type:varchar(50);not null;index:idx_collection_category;column:category"`
+ LikeCount int32 `gorm:"default:0;column:like_count"`
+ Status int32 `gorm:"default:0;column:status"` // 0=Pending, 1=Active
+ Metadata JSONB `gorm:"type:jsonb;column:metadata"`
+ CreatedAt int64 `gorm:"not null;column:created_at"`
+ UpdatedAt int64 `gorm:"not null;column:updated_at"`
+}
+
+// TableName 指定表名
+func (CollectionAsset) TableName() string {
+ return "collection_assets"
+}
+
+// BeforeCreate 创建前钩子
+func (c *CollectionAsset) BeforeCreate(tx *gorm.DB) error {
+ now := time.Now().UnixMilli()
+ c.CreatedAt = now
+ c.UpdatedAt = now
+ if c.Status == 0 {
+ c.Status = AssetStatusPending
+ }
+ return nil
+}
+
+// BeforeUpdate 更新前钩子
+func (c *CollectionAsset) BeforeUpdate(tx *gorm.DB) error {
+ c.UpdatedAt = time.Now().UnixMilli()
+ return nil
+}
+
+// 典藏藏品状态常量
+const (
+ CollectionAssetStatusPending = 0 // 待处理
+ CollectionAssetStatusActive = 1 // 已激活
+)
+
+// JSONB 是 GORM 的 jsonb 类型别名
+type JSONB []byte
+
+// Scan 实现 sql.Scanner 接口
+func (j *JSONB) Scan(value interface{}) error {
+ if value == nil {
+ *j = nil
+ return nil
+ }
+ bytes, ok := value.([]byte)
+ if !ok {
+ *j = nil
+ return nil
+ }
+ *j = bytes
+ return nil
+}
diff --git a/backend/pkg/proto/asset/asset.pb.go b/backend/pkg/proto/asset/asset.pb.go
index 8e15344..5b859f1 100644
--- a/backend/pkg/proto/asset/asset.pb.go
+++ b/backend/pkg/proto/asset/asset.pb.go
@@ -33,7 +33,7 @@ type Asset struct {
CoverUrl string `protobuf:"bytes,5,opt,name=cover_url,json=coverUrl,proto3" json:"cover_url,omitempty"` // 封面图URL
MaterialUrl string `protobuf:"bytes,6,opt,name=material_url,json=materialUrl,proto3" json:"material_url,omitempty"` // 用户上传的素材URL
Description string `protobuf:"bytes,7,opt,name=description,proto3" json:"description,omitempty"` // 藏品描述(可选)
- Rarity int32 `protobuf:"varint,8,opt,name=rarity,proto3" json:"rarity,omitempty"` // 稀有度(可选)
+ Grade int32 `protobuf:"varint,8,opt,name=grade,proto3" json:"grade,omitempty"` // 星册等级(可选)
Tags []string `protobuf:"bytes,9,rep,name=tags,proto3" json:"tags,omitempty"` // 标签数组(可选)
Visibility string `protobuf:"bytes,10,opt,name=visibility,proto3" json:"visibility,omitempty"` // 可见性:private, friends, public(预留)
Status int32 `protobuf:"varint,11,opt,name=status,proto3" json:"status,omitempty"` // 状态:0=Pending, 1=Active
@@ -131,9 +131,9 @@ func (x *Asset) GetDescription() string {
return ""
}
-func (x *Asset) GetRarity() int32 {
+func (x *Asset) GetGrade() int32 {
if x != nil {
- return x.Rarity
+ return x.Grade
}
return 0
}
@@ -567,7 +567,7 @@ type CreateMintOrderRequest struct {
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // 藏品名称(可选)
MaterialUrl string `protobuf:"bytes,3,opt,name=material_url,json=materialUrl,proto3" json:"material_url,omitempty"` // 用户上传的素材URL(必填,前端已上传到OSS)
Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` // 藏品描述(可选)
- Rarity int32 `protobuf:"varint,5,opt,name=rarity,proto3" json:"rarity,omitempty"` // 稀有度(可选)
+ Grade int32 `protobuf:"varint,5,opt,name=grade,proto3" json:"grade,omitempty"` // 星册等级(可选)
Tags []string `protobuf:"bytes,6,rep,name=tags,proto3" json:"tags,omitempty"` // 标签列表(可选)
MaterialType string `protobuf:"bytes,7,opt,name=material_type,json=materialType,proto3" json:"material_type,omitempty"` // 素材类型(可选)
Info string `protobuf:"bytes,8,opt,name=info,proto3" json:"info,omitempty"` // 藏品信息(必填)
@@ -633,9 +633,9 @@ func (x *CreateMintOrderRequest) GetDescription() string {
return ""
}
-func (x *CreateMintOrderRequest) GetRarity() int32 {
+func (x *CreateMintOrderRequest) GetGrade() int32 {
if x != nil {
- return x.Rarity
+ return x.Grade
}
return 0
}
@@ -2302,7 +2302,7 @@ var File_asset_proto protoreflect.FileDescriptor
const file_asset_proto_rawDesc = "" +
"\n" +
- "\vasset.proto\x12\rtopfans.asset\x1a\x12proto/common.proto\x1a\x1cgoogle/api/annotations.proto\"\xee\x04\n" +
+ "\vasset.proto\x12\rtopfans.asset\x1a\x12proto/common.proto\x1a\x1cgoogle/api/annotations.proto\"\xec\x04\n" +
"\x05Asset\x12\x19\n" +
"\basset_id\x18\x01 \x01(\x03R\aassetId\x12\x1b\n" +
"\towner_uid\x18\x02 \x01(\x03R\bownerUid\x12\x17\n" +
@@ -2310,8 +2310,8 @@ const file_asset_proto_rawDesc = "" +
"\x04name\x18\x04 \x01(\tR\x04name\x12\x1b\n" +
"\tcover_url\x18\x05 \x01(\tR\bcoverUrl\x12!\n" +
"\fmaterial_url\x18\x06 \x01(\tR\vmaterialUrl\x12 \n" +
- "\vdescription\x18\a \x01(\tR\vdescription\x12\x16\n" +
- "\x06rarity\x18\b \x01(\x05R\x06rarity\x12\x12\n" +
+ "\vdescription\x18\a \x01(\tR\vdescription\x12\x14\n" +
+ "\x05grade\x18\b \x01(\x05R\x05grade\x12\x12\n" +
"\x04tags\x18\t \x03(\tR\x04tags\x12\x1e\n" +
"\n" +
"visibility\x18\n" +
@@ -2361,13 +2361,13 @@ const file_asset_proto_rawDesc = "" +
"\border_id\x18\x01 \x01(\tR\aorderId\"y\n" +
"\x15InitMintOrderResponse\x120\n" +
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12.\n" +
- "\x05order\x18\x02 \x01(\v2\x18.topfans.asset.MintOrderR\x05order\"\xf1\x01\n" +
+ "\x05order\x18\x02 \x01(\v2\x18.topfans.asset.MintOrderR\x05order\"\xef\x01\n" +
"\x16CreateMintOrderRequest\x12\x19\n" +
"\border_id\x18\x02 \x01(\tR\aorderId\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12!\n" +
"\fmaterial_url\x18\x03 \x01(\tR\vmaterialUrl\x12 \n" +
- "\vdescription\x18\x04 \x01(\tR\vdescription\x12\x16\n" +
- "\x06rarity\x18\x05 \x01(\x05R\x06rarity\x12\x12\n" +
+ "\vdescription\x18\x04 \x01(\tR\vdescription\x12\x14\n" +
+ "\x05grade\x18\x05 \x01(\x05R\x05grade\x12\x12\n" +
"\x04tags\x18\x06 \x03(\tR\x04tags\x12#\n" +
"\rmaterial_type\x18\a \x01(\tR\fmaterialType\x12\x12\n" +
"\x04info\x18\b \x01(\tR\x04info\"\xaf\x01\n" +
diff --git a/backend/pkg/proto/starbook/starbook.pb.go b/backend/pkg/proto/starbook/starbook.pb.go
new file mode 100644
index 0000000..9c908c7
--- /dev/null
+++ b/backend/pkg/proto/starbook/starbook.pb.go
@@ -0,0 +1,748 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.36.11
+// protoc v7.34.0
+// source: starbook.proto
+
+package starbook
+
+import (
+ common "github.com/topfans/backend/pkg/proto/common"
+ _ "google.golang.org/genproto/googleapis/api/annotations"
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ reflect "reflect"
+ sync "sync"
+ unsafe "unsafe"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// 星册首页请求
+type GetStarbookHomeRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *GetStarbookHomeRequest) Reset() {
+ *x = GetStarbookHomeRequest{}
+ mi := &file_starbook_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *GetStarbookHomeRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetStarbookHomeRequest) ProtoMessage() {}
+
+func (x *GetStarbookHomeRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_starbook_proto_msgTypes[0]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetStarbookHomeRequest.ProtoReflect.Descriptor instead.
+func (*GetStarbookHomeRequest) Descriptor() ([]byte, []int) {
+ return file_starbook_proto_rawDescGZIP(), []int{0}
+}
+
+// 星册首页响应
+type GetStarbookHomeResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Base *common.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"`
+ Data *StarbookHomeData `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *GetStarbookHomeResponse) Reset() {
+ *x = GetStarbookHomeResponse{}
+ mi := &file_starbook_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *GetStarbookHomeResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetStarbookHomeResponse) ProtoMessage() {}
+
+func (x *GetStarbookHomeResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_starbook_proto_msgTypes[1]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetStarbookHomeResponse.ProtoReflect.Descriptor instead.
+func (*GetStarbookHomeResponse) Descriptor() ([]byte, []int) {
+ return file_starbook_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *GetStarbookHomeResponse) GetBase() *common.BaseResponse {
+ if x != nil {
+ return x.Base
+ }
+ return nil
+}
+
+func (x *GetStarbookHomeResponse) GetData() *StarbookHomeData {
+ if x != nil {
+ return x.Data
+ }
+ return nil
+}
+
+// 星册首页数据
+type StarbookHomeData struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Groups []*AssetGroup `protobuf:"bytes,1,rep,name=groups,proto3" json:"groups,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *StarbookHomeData) Reset() {
+ *x = StarbookHomeData{}
+ mi := &file_starbook_proto_msgTypes[2]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *StarbookHomeData) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*StarbookHomeData) ProtoMessage() {}
+
+func (x *StarbookHomeData) ProtoReflect() protoreflect.Message {
+ mi := &file_starbook_proto_msgTypes[2]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use StarbookHomeData.ProtoReflect.Descriptor instead.
+func (*StarbookHomeData) Descriptor() ([]byte, []int) {
+ return file_starbook_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *StarbookHomeData) GetGroups() []*AssetGroup {
+ if x != nil {
+ return x.Groups
+ }
+ return nil
+}
+
+// 资产分组
+type AssetGroup struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` // 'regular' / 'collection' / 'activity'
+ Category string `protobuf:"bytes,2,opt,name=category,proto3" json:"category,omitempty"` // 'castlove'(regular) / collection_category / activity_type
+ CategoryName string `protobuf:"bytes,3,opt,name=category_name,json=categoryName,proto3" json:"category_name,omitempty"`
+ // regular 使用 grades 分组;collection/activity 使用 flat items 列表
+ Grades []*GradeSection `protobuf:"bytes,4,rep,name=grades,proto3" json:"grades,omitempty"` // 仅 regular 时有效
+ Items []*AssetItem `protobuf:"bytes,5,rep,name=items,proto3" json:"items,omitempty"` // collection / activity 时有效
+ TotalCount int32 `protobuf:"varint,6,opt,name=total_count,json=totalCount,proto3" json:"total_count,omitempty"`
+ HasMore bool `protobuf:"varint,7,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *AssetGroup) Reset() {
+ *x = AssetGroup{}
+ mi := &file_starbook_proto_msgTypes[3]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *AssetGroup) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AssetGroup) ProtoMessage() {}
+
+func (x *AssetGroup) ProtoReflect() protoreflect.Message {
+ mi := &file_starbook_proto_msgTypes[3]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use AssetGroup.ProtoReflect.Descriptor instead.
+func (*AssetGroup) Descriptor() ([]byte, []int) {
+ return file_starbook_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *AssetGroup) GetType() string {
+ if x != nil {
+ return x.Type
+ }
+ return ""
+}
+
+func (x *AssetGroup) GetCategory() string {
+ if x != nil {
+ return x.Category
+ }
+ return ""
+}
+
+func (x *AssetGroup) GetCategoryName() string {
+ if x != nil {
+ return x.CategoryName
+ }
+ return ""
+}
+
+func (x *AssetGroup) GetGrades() []*GradeSection {
+ if x != nil {
+ return x.Grades
+ }
+ return nil
+}
+
+func (x *AssetGroup) GetItems() []*AssetItem {
+ if x != nil {
+ return x.Items
+ }
+ return nil
+}
+
+func (x *AssetGroup) GetTotalCount() int32 {
+ if x != nil {
+ return x.TotalCount
+ }
+ return 0
+}
+
+func (x *AssetGroup) GetHasMore() bool {
+ if x != nil {
+ return x.HasMore
+ }
+ return false
+}
+
+// 等级分组(仅 regular 类型使用)
+type GradeSection struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Grade int32 `protobuf:"varint,1,opt,name=grade,proto3" json:"grade,omitempty"` // 等级:1/2/3/4/5...
+ Items []*AssetItem `protobuf:"bytes,2,rep,name=items,proto3" json:"items,omitempty"`
+ TotalCount int32 `protobuf:"varint,3,opt,name=total_count,json=totalCount,proto3" json:"total_count,omitempty"`
+ HasMore bool `protobuf:"varint,4,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *GradeSection) Reset() {
+ *x = GradeSection{}
+ mi := &file_starbook_proto_msgTypes[4]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *GradeSection) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GradeSection) ProtoMessage() {}
+
+func (x *GradeSection) ProtoReflect() protoreflect.Message {
+ mi := &file_starbook_proto_msgTypes[4]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use GradeSection.ProtoReflect.Descriptor instead.
+func (*GradeSection) Descriptor() ([]byte, []int) {
+ return file_starbook_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *GradeSection) GetGrade() int32 {
+ if x != nil {
+ return x.Grade
+ }
+ return 0
+}
+
+func (x *GradeSection) GetItems() []*AssetItem {
+ if x != nil {
+ return x.Items
+ }
+ return nil
+}
+
+func (x *GradeSection) GetTotalCount() int32 {
+ if x != nil {
+ return x.TotalCount
+ }
+ return 0
+}
+
+func (x *GradeSection) GetHasMore() bool {
+ if x != nil {
+ return x.HasMore
+ }
+ return false
+}
+
+// 资产项
+type AssetItem struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ AssetId int64 `protobuf:"varint,1,opt,name=asset_id,json=assetId,proto3" json:"asset_id,omitempty"`
+ Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
+ CoverUrlSigned string `protobuf:"bytes,3,opt,name=cover_url_signed,json=coverUrlSigned,proto3" json:"cover_url_signed,omitempty"` // 预签名封面URL
+ LikeCount int32 `protobuf:"varint,4,opt,name=like_count,json=likeCount,proto3" json:"like_count,omitempty"`
+ CreatedAt int64 `protobuf:"varint,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
+ Category string `protobuf:"bytes,6,opt,name=category,proto3" json:"category,omitempty"` // regular: 'castlove' / collection: category / activity: activity_type
+ Grade int32 `protobuf:"varint,7,opt,name=grade,proto3" json:"grade,omitempty"` // 仅 regular 时有效(1/2/3...),其他类型为 0
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *AssetItem) Reset() {
+ *x = AssetItem{}
+ mi := &file_starbook_proto_msgTypes[5]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *AssetItem) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AssetItem) ProtoMessage() {}
+
+func (x *AssetItem) ProtoReflect() protoreflect.Message {
+ mi := &file_starbook_proto_msgTypes[5]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use AssetItem.ProtoReflect.Descriptor instead.
+func (*AssetItem) Descriptor() ([]byte, []int) {
+ return file_starbook_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *AssetItem) GetAssetId() int64 {
+ if x != nil {
+ return x.AssetId
+ }
+ return 0
+}
+
+func (x *AssetItem) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *AssetItem) GetCoverUrlSigned() string {
+ if x != nil {
+ return x.CoverUrlSigned
+ }
+ return ""
+}
+
+func (x *AssetItem) GetLikeCount() int32 {
+ if x != nil {
+ return x.LikeCount
+ }
+ return 0
+}
+
+func (x *AssetItem) GetCreatedAt() int64 {
+ if x != nil {
+ return x.CreatedAt
+ }
+ return 0
+}
+
+func (x *AssetItem) GetCategory() string {
+ if x != nil {
+ return x.Category
+ }
+ return ""
+}
+
+func (x *AssetItem) GetGrade() int32 {
+ if x != nil {
+ return x.Grade
+ }
+ return 0
+}
+
+// 藏品列表请求
+type GetStarbookItemsRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` // 'regular' / 'collection' / 'activity'
+ Category string `protobuf:"bytes,2,opt,name=category,proto3" json:"category,omitempty"` // regular 时固定传 'castlove'
+ Grade int32 `protobuf:"varint,3,opt,name=grade,proto3" json:"grade,omitempty"` // 仅 regular 时有效(1/2/3...)
+ Page int32 `protobuf:"varint,4,opt,name=page,proto3" json:"page,omitempty"` // 默认 1
+ PageSize int32 `protobuf:"varint,5,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` // 默认 20
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *GetStarbookItemsRequest) Reset() {
+ *x = GetStarbookItemsRequest{}
+ mi := &file_starbook_proto_msgTypes[6]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *GetStarbookItemsRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetStarbookItemsRequest) ProtoMessage() {}
+
+func (x *GetStarbookItemsRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_starbook_proto_msgTypes[6]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetStarbookItemsRequest.ProtoReflect.Descriptor instead.
+func (*GetStarbookItemsRequest) Descriptor() ([]byte, []int) {
+ return file_starbook_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *GetStarbookItemsRequest) GetType() string {
+ if x != nil {
+ return x.Type
+ }
+ return ""
+}
+
+func (x *GetStarbookItemsRequest) GetCategory() string {
+ if x != nil {
+ return x.Category
+ }
+ return ""
+}
+
+func (x *GetStarbookItemsRequest) GetGrade() int32 {
+ if x != nil {
+ return x.Grade
+ }
+ return 0
+}
+
+func (x *GetStarbookItemsRequest) GetPage() int32 {
+ if x != nil {
+ return x.Page
+ }
+ return 0
+}
+
+func (x *GetStarbookItemsRequest) GetPageSize() int32 {
+ if x != nil {
+ return x.PageSize
+ }
+ return 0
+}
+
+// 藏品列表响应
+type GetStarbookItemsResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Base *common.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"`
+ Data *AssetListData `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *GetStarbookItemsResponse) Reset() {
+ *x = GetStarbookItemsResponse{}
+ mi := &file_starbook_proto_msgTypes[7]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *GetStarbookItemsResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetStarbookItemsResponse) ProtoMessage() {}
+
+func (x *GetStarbookItemsResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_starbook_proto_msgTypes[7]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetStarbookItemsResponse.ProtoReflect.Descriptor instead.
+func (*GetStarbookItemsResponse) Descriptor() ([]byte, []int) {
+ return file_starbook_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *GetStarbookItemsResponse) GetBase() *common.BaseResponse {
+ if x != nil {
+ return x.Base
+ }
+ return nil
+}
+
+func (x *GetStarbookItemsResponse) GetData() *AssetListData {
+ if x != nil {
+ return x.Data
+ }
+ return nil
+}
+
+// 藏品列表数据
+type AssetListData struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Items []*AssetItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
+ Total int64 `protobuf:"varint,2,opt,name=total,proto3" json:"total,omitempty"`
+ Page int32 `protobuf:"varint,3,opt,name=page,proto3" json:"page,omitempty"`
+ PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
+ HasMore bool `protobuf:"varint,5,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *AssetListData) Reset() {
+ *x = AssetListData{}
+ mi := &file_starbook_proto_msgTypes[8]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *AssetListData) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AssetListData) ProtoMessage() {}
+
+func (x *AssetListData) ProtoReflect() protoreflect.Message {
+ mi := &file_starbook_proto_msgTypes[8]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use AssetListData.ProtoReflect.Descriptor instead.
+func (*AssetListData) Descriptor() ([]byte, []int) {
+ return file_starbook_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *AssetListData) GetItems() []*AssetItem {
+ if x != nil {
+ return x.Items
+ }
+ return nil
+}
+
+func (x *AssetListData) GetTotal() int64 {
+ if x != nil {
+ return x.Total
+ }
+ return 0
+}
+
+func (x *AssetListData) GetPage() int32 {
+ if x != nil {
+ return x.Page
+ }
+ return 0
+}
+
+func (x *AssetListData) GetPageSize() int32 {
+ if x != nil {
+ return x.PageSize
+ }
+ return 0
+}
+
+func (x *AssetListData) GetHasMore() bool {
+ if x != nil {
+ return x.HasMore
+ }
+ return false
+}
+
+var File_starbook_proto protoreflect.FileDescriptor
+
+const file_starbook_proto_rawDesc = "" +
+ "\n" +
+ "\x0estarbook.proto\x12\x10topfans.starbook\x1a\x12proto/common.proto\x1a\x1cgoogle/api/annotations.proto\"\x18\n" +
+ "\x16GetStarbookHomeRequest\"\x83\x01\n" +
+ "\x17GetStarbookHomeResponse\x120\n" +
+ "\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x126\n" +
+ "\x04data\x18\x02 \x01(\v2\".topfans.starbook.StarbookHomeDataR\x04data\"H\n" +
+ "\x10StarbookHomeData\x124\n" +
+ "\x06groups\x18\x01 \x03(\v2\x1c.topfans.starbook.AssetGroupR\x06groups\"\x88\x02\n" +
+ "\n" +
+ "AssetGroup\x12\x12\n" +
+ "\x04type\x18\x01 \x01(\tR\x04type\x12\x1a\n" +
+ "\bcategory\x18\x02 \x01(\tR\bcategory\x12#\n" +
+ "\rcategory_name\x18\x03 \x01(\tR\fcategoryName\x126\n" +
+ "\x06grades\x18\x04 \x03(\v2\x1e.topfans.starbook.GradeSectionR\x06grades\x121\n" +
+ "\x05items\x18\x05 \x03(\v2\x1b.topfans.starbook.AssetItemR\x05items\x12\x1f\n" +
+ "\vtotal_count\x18\x06 \x01(\x05R\n" +
+ "totalCount\x12\x19\n" +
+ "\bhas_more\x18\a \x01(\bR\ahasMore\"\x93\x01\n" +
+ "\fGradeSection\x12\x14\n" +
+ "\x05grade\x18\x01 \x01(\x05R\x05grade\x121\n" +
+ "\x05items\x18\x02 \x03(\v2\x1b.topfans.starbook.AssetItemR\x05items\x12\x1f\n" +
+ "\vtotal_count\x18\x03 \x01(\x05R\n" +
+ "totalCount\x12\x19\n" +
+ "\bhas_more\x18\x04 \x01(\bR\ahasMore\"\xd4\x01\n" +
+ "\tAssetItem\x12\x19\n" +
+ "\basset_id\x18\x01 \x01(\x03R\aassetId\x12\x12\n" +
+ "\x04name\x18\x02 \x01(\tR\x04name\x12(\n" +
+ "\x10cover_url_signed\x18\x03 \x01(\tR\x0ecoverUrlSigned\x12\x1d\n" +
+ "\n" +
+ "like_count\x18\x04 \x01(\x05R\tlikeCount\x12\x1d\n" +
+ "\n" +
+ "created_at\x18\x05 \x01(\x03R\tcreatedAt\x12\x1a\n" +
+ "\bcategory\x18\x06 \x01(\tR\bcategory\x12\x14\n" +
+ "\x05grade\x18\a \x01(\x05R\x05grade\"\x90\x01\n" +
+ "\x17GetStarbookItemsRequest\x12\x12\n" +
+ "\x04type\x18\x01 \x01(\tR\x04type\x12\x1a\n" +
+ "\bcategory\x18\x02 \x01(\tR\bcategory\x12\x14\n" +
+ "\x05grade\x18\x03 \x01(\x05R\x05grade\x12\x12\n" +
+ "\x04page\x18\x04 \x01(\x05R\x04page\x12\x1b\n" +
+ "\tpage_size\x18\x05 \x01(\x05R\bpageSize\"\x81\x01\n" +
+ "\x18GetStarbookItemsResponse\x120\n" +
+ "\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x123\n" +
+ "\x04data\x18\x02 \x01(\v2\x1f.topfans.starbook.AssetListDataR\x04data\"\xa4\x01\n" +
+ "\rAssetListData\x121\n" +
+ "\x05items\x18\x01 \x03(\v2\x1b.topfans.starbook.AssetItemR\x05items\x12\x14\n" +
+ "\x05total\x18\x02 \x01(\x03R\x05total\x12\x12\n" +
+ "\x04page\x18\x03 \x01(\x05R\x04page\x12\x1b\n" +
+ "\tpage_size\x18\x04 \x01(\x05R\bpageSize\x12\x19\n" +
+ "\bhas_more\x18\x05 \x01(\bR\ahasMore2\xa5\x02\n" +
+ "\x0fStarbookService\x12\x85\x01\n" +
+ "\x0fGetStarbookHome\x12(.topfans.starbook.GetStarbookHomeRequest\x1a).topfans.starbook.GetStarbookHomeResponse\"\x1d\x82\xd3\xe4\x93\x02\x17\x12\x15/api/v1/starbook/home\x12\x89\x01\n" +
+ "\x10GetStarbookItems\x12).topfans.starbook.GetStarbookItemsRequest\x1a*.topfans.starbook.GetStarbookItemsResponse\"\x1e\x82\xd3\xe4\x93\x02\x18\x12\x16/api/v1/starbook/itemsB8Z6github.com/topfans/backend/pkg/proto/starbook;starbookb\x06proto3"
+
+var (
+ file_starbook_proto_rawDescOnce sync.Once
+ file_starbook_proto_rawDescData []byte
+)
+
+func file_starbook_proto_rawDescGZIP() []byte {
+ file_starbook_proto_rawDescOnce.Do(func() {
+ file_starbook_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_starbook_proto_rawDesc), len(file_starbook_proto_rawDesc)))
+ })
+ return file_starbook_proto_rawDescData
+}
+
+var file_starbook_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
+var file_starbook_proto_goTypes = []any{
+ (*GetStarbookHomeRequest)(nil), // 0: topfans.starbook.GetStarbookHomeRequest
+ (*GetStarbookHomeResponse)(nil), // 1: topfans.starbook.GetStarbookHomeResponse
+ (*StarbookHomeData)(nil), // 2: topfans.starbook.StarbookHomeData
+ (*AssetGroup)(nil), // 3: topfans.starbook.AssetGroup
+ (*GradeSection)(nil), // 4: topfans.starbook.GradeSection
+ (*AssetItem)(nil), // 5: topfans.starbook.AssetItem
+ (*GetStarbookItemsRequest)(nil), // 6: topfans.starbook.GetStarbookItemsRequest
+ (*GetStarbookItemsResponse)(nil), // 7: topfans.starbook.GetStarbookItemsResponse
+ (*AssetListData)(nil), // 8: topfans.starbook.AssetListData
+ (*common.BaseResponse)(nil), // 9: topfans.common.BaseResponse
+}
+var file_starbook_proto_depIdxs = []int32{
+ 9, // 0: topfans.starbook.GetStarbookHomeResponse.base:type_name -> topfans.common.BaseResponse
+ 2, // 1: topfans.starbook.GetStarbookHomeResponse.data:type_name -> topfans.starbook.StarbookHomeData
+ 3, // 2: topfans.starbook.StarbookHomeData.groups:type_name -> topfans.starbook.AssetGroup
+ 4, // 3: topfans.starbook.AssetGroup.grades:type_name -> topfans.starbook.GradeSection
+ 5, // 4: topfans.starbook.AssetGroup.items:type_name -> topfans.starbook.AssetItem
+ 5, // 5: topfans.starbook.GradeSection.items:type_name -> topfans.starbook.AssetItem
+ 9, // 6: topfans.starbook.GetStarbookItemsResponse.base:type_name -> topfans.common.BaseResponse
+ 8, // 7: topfans.starbook.GetStarbookItemsResponse.data:type_name -> topfans.starbook.AssetListData
+ 5, // 8: topfans.starbook.AssetListData.items:type_name -> topfans.starbook.AssetItem
+ 0, // 9: topfans.starbook.StarbookService.GetStarbookHome:input_type -> topfans.starbook.GetStarbookHomeRequest
+ 6, // 10: topfans.starbook.StarbookService.GetStarbookItems:input_type -> topfans.starbook.GetStarbookItemsRequest
+ 1, // 11: topfans.starbook.StarbookService.GetStarbookHome:output_type -> topfans.starbook.GetStarbookHomeResponse
+ 7, // 12: topfans.starbook.StarbookService.GetStarbookItems:output_type -> topfans.starbook.GetStarbookItemsResponse
+ 11, // [11:13] is the sub-list for method output_type
+ 9, // [9:11] is the sub-list for method input_type
+ 9, // [9:9] is the sub-list for extension type_name
+ 9, // [9:9] is the sub-list for extension extendee
+ 0, // [0:9] is the sub-list for field type_name
+}
+
+func init() { file_starbook_proto_init() }
+func file_starbook_proto_init() {
+ if File_starbook_proto != nil {
+ return
+ }
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_starbook_proto_rawDesc), len(file_starbook_proto_rawDesc)),
+ NumEnums: 0,
+ NumMessages: 9,
+ NumExtensions: 0,
+ NumServices: 1,
+ },
+ GoTypes: file_starbook_proto_goTypes,
+ DependencyIndexes: file_starbook_proto_depIdxs,
+ MessageInfos: file_starbook_proto_msgTypes,
+ }.Build()
+ File_starbook_proto = out.File
+ file_starbook_proto_goTypes = nil
+ file_starbook_proto_depIdxs = nil
+}
diff --git a/backend/pkg/proto/starbook/starbook.triple.go b/backend/pkg/proto/starbook/starbook.triple.go
new file mode 100644
index 0000000..a3f48d1
--- /dev/null
+++ b/backend/pkg/proto/starbook/starbook.triple.go
@@ -0,0 +1,149 @@
+// Code generated by protoc-gen-triple. DO NOT EDIT.
+//
+// Source: starbook.proto
+package starbook
+
+import (
+ "context"
+)
+
+import (
+ "dubbo.apache.org/dubbo-go/v3"
+ "dubbo.apache.org/dubbo-go/v3/client"
+ "dubbo.apache.org/dubbo-go/v3/common"
+ "dubbo.apache.org/dubbo-go/v3/common/constant"
+ "dubbo.apache.org/dubbo-go/v3/protocol/triple/triple_protocol"
+ "dubbo.apache.org/dubbo-go/v3/server"
+)
+
+// This is a compile-time assertion to ensure that this generated file and the Triple package
+// are compatible. If you get a compiler error that this constant is not defined, this code was
+// generated with a version of Triple newer than the one compiled into your binary. You can fix the
+// problem by either regenerating this code with an older version of Triple or updating the Triple
+// version compiled into your binary.
+const _ = triple_protocol.IsAtLeastVersion0_1_0
+
+const (
+ // StarbookServiceName is the fully-qualified name of the StarbookService service.
+ StarbookServiceName = "topfans.starbook.StarbookService"
+)
+
+// These constants are the fully-qualified names of the RPCs defined in this package. They're
+// exposed at runtime as procedure and as the final two segments of the HTTP route.
+//
+// Note that these are different from the fully-qualified method names used by
+// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to
+// reflection-formatted method names, remove the leading slash and convert the remaining slash to a
+// period.
+const (
+ // StarbookServiceGetStarbookHomeProcedure is the fully-qualified name of the StarbookService's GetStarbookHome RPC.
+ StarbookServiceGetStarbookHomeProcedure = "/topfans.starbook.StarbookService/GetStarbookHome"
+ // StarbookServiceGetStarbookItemsProcedure is the fully-qualified name of the StarbookService's GetStarbookItems RPC.
+ StarbookServiceGetStarbookItemsProcedure = "/topfans.starbook.StarbookService/GetStarbookItems"
+)
+
+var (
+ _ StarbookService = (*StarbookServiceImpl)(nil)
+)
+
+// StarbookService is a client for the topfans.starbook.StarbookService service.
+type StarbookService interface {
+ GetStarbookHome(ctx context.Context, req *GetStarbookHomeRequest, opts ...client.CallOption) (*GetStarbookHomeResponse, error)
+ GetStarbookItems(ctx context.Context, req *GetStarbookItemsRequest, opts ...client.CallOption) (*GetStarbookItemsResponse, error)
+}
+
+// NewStarbookService constructs a client for the starbook.StarbookService service.
+func NewStarbookService(cli *client.Client, opts ...client.ReferenceOption) (StarbookService, error) {
+ conn, err := cli.DialWithInfo("topfans.starbook.StarbookService", &StarbookService_ClientInfo, opts...)
+ if err != nil {
+ return nil, err
+ }
+ return &StarbookServiceImpl{
+ conn: conn,
+ }, nil
+}
+
+func SetConsumerStarbookService(srv common.RPCService) {
+ dubbo.SetConsumerServiceWithInfo(srv, &StarbookService_ClientInfo)
+}
+
+// StarbookServiceImpl implements StarbookService.
+type StarbookServiceImpl struct {
+ conn *client.Connection
+}
+
+func (c *StarbookServiceImpl) GetStarbookHome(ctx context.Context, req *GetStarbookHomeRequest, opts ...client.CallOption) (*GetStarbookHomeResponse, error) {
+ resp := new(GetStarbookHomeResponse)
+ if err := c.conn.CallUnary(ctx, []interface{}{req}, resp, "GetStarbookHome", opts...); err != nil {
+ return nil, err
+ }
+ return resp, nil
+}
+
+func (c *StarbookServiceImpl) GetStarbookItems(ctx context.Context, req *GetStarbookItemsRequest, opts ...client.CallOption) (*GetStarbookItemsResponse, error) {
+ resp := new(GetStarbookItemsResponse)
+ if err := c.conn.CallUnary(ctx, []interface{}{req}, resp, "GetStarbookItems", opts...); err != nil {
+ return nil, err
+ }
+ return resp, nil
+}
+
+var StarbookService_ClientInfo = client.ClientInfo{
+ InterfaceName: "topfans.starbook.StarbookService",
+ MethodNames: []string{"GetStarbookHome", "GetStarbookItems"},
+ ConnectionInjectFunc: func(dubboCliRaw interface{}, conn *client.Connection) {
+ dubboCli := dubboCliRaw.(*StarbookServiceImpl)
+ dubboCli.conn = conn
+ },
+}
+
+// StarbookServiceHandler is an implementation of the topfans.starbook.StarbookService service.
+type StarbookServiceHandler interface {
+ GetStarbookHome(context.Context, *GetStarbookHomeRequest) (*GetStarbookHomeResponse, error)
+ GetStarbookItems(context.Context, *GetStarbookItemsRequest) (*GetStarbookItemsResponse, error)
+}
+
+func RegisterStarbookServiceHandler(srv *server.Server, hdlr StarbookServiceHandler, opts ...server.ServiceOption) error {
+ return srv.Register(hdlr, &StarbookService_ServiceInfo, opts...)
+}
+
+func SetProviderStarbookService(srv common.RPCService) {
+ dubbo.SetProviderServiceWithInfo(srv, &StarbookService_ServiceInfo)
+}
+
+var StarbookService_ServiceInfo = server.ServiceInfo{
+ InterfaceName: "topfans.starbook.StarbookService",
+ ServiceType: (*StarbookServiceHandler)(nil),
+ Methods: []server.MethodInfo{
+ {
+ Name: "GetStarbookHome",
+ Type: constant.CallUnary,
+ ReqInitFunc: func() interface{} {
+ return new(GetStarbookHomeRequest)
+ },
+ MethodFunc: func(ctx context.Context, args []interface{}, handler interface{}) (interface{}, error) {
+ req := args[0].(*GetStarbookHomeRequest)
+ res, err := handler.(StarbookServiceHandler).GetStarbookHome(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ return triple_protocol.NewResponse(res), nil
+ },
+ },
+ {
+ Name: "GetStarbookItems",
+ Type: constant.CallUnary,
+ ReqInitFunc: func() interface{} {
+ return new(GetStarbookItemsRequest)
+ },
+ MethodFunc: func(ctx context.Context, args []interface{}, handler interface{}) (interface{}, error) {
+ req := args[0].(*GetStarbookItemsRequest)
+ res, err := handler.(StarbookServiceHandler).GetStarbookItems(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ return triple_protocol.NewResponse(res), nil
+ },
+ },
+ },
+}
diff --git a/backend/proto/asset.proto b/backend/proto/asset.proto
index e0838aa..fdb51d4 100644
--- a/backend/proto/asset.proto
+++ b/backend/proto/asset.proto
@@ -18,7 +18,7 @@ message Asset {
string cover_url = 5; // 封面图URL
string material_url = 6; // 用户上传的素材URL
string description = 7; // 藏品描述(可选)
- int32 rarity = 8; // 稀有度(可选)
+ int32 grade = 8; // 星册等级(可选)
repeated string tags = 9; // 标签数组(可选)
string visibility = 10; // 可见性:private, friends, public(预留)
int32 status = 11; // 状态:0=Pending, 1=Active
@@ -82,7 +82,7 @@ message CreateMintOrderRequest {
string name = 1; // 藏品名称(可选)
string material_url = 3; // 用户上传的素材URL(必填,前端已上传到OSS)
string description = 4; // 藏品描述(可选)
- int32 rarity = 5; // 稀有度(可选)
+ int32 grade = 5; // 星册等级(可选)
repeated string tags = 6; // 标签列表(可选)
string material_type = 7; // 素材类型(可选)
string info = 8; // 藏品信息(必填)
diff --git a/backend/proto/starbook.proto b/backend/proto/starbook.proto
new file mode 100644
index 0000000..578c132
--- /dev/null
+++ b/backend/proto/starbook.proto
@@ -0,0 +1,99 @@
+syntax = "proto3";
+
+package topfans.starbook;
+
+option go_package = "github.com/topfans/backend/pkg/proto/starbook;starbook";
+
+import "proto/common.proto";
+import "google/api/annotations.proto";
+
+// 星册服务 - 典藏/活动藏品体系重构
+service StarbookService {
+ // 星册首页
+ rpc GetStarbookHome(GetStarbookHomeRequest) returns (GetStarbookHomeResponse) {
+ option (google.api.http) = {
+ get: "/api/v1/starbook/home"
+ };
+ }
+
+ // 藏品列表(分页)
+ rpc GetStarbookItems(GetStarbookItemsRequest) returns (GetStarbookItemsResponse) {
+ option (google.api.http) = {
+ get: "/api/v1/starbook/items"
+ };
+ }
+}
+
+// ==================== 星册首页 ====================
+
+// 星册首页请求
+message GetStarbookHomeRequest {
+}
+
+// 星册首页响应
+message GetStarbookHomeResponse {
+ topfans.common.BaseResponse base = 1;
+ StarbookHomeData data = 2;
+}
+
+// 星册首页数据
+message StarbookHomeData {
+ repeated AssetGroup groups = 1;
+}
+
+// 资产分组
+message AssetGroup {
+ string type = 1; // 'regular' / 'collection' / 'activity'
+ string category = 2; // 'castlove'(regular) / collection_category / activity_type
+ string category_name = 3;
+ // regular 使用 grades 分组;collection/activity 使用 flat items 列表
+ repeated GradeSection grades = 4; // 仅 regular 时有效
+ repeated AssetItem items = 5; // collection / activity 时有效
+ int32 total_count = 6;
+ bool has_more = 7;
+}
+
+// 等级分组(仅 regular 类型使用)
+message GradeSection {
+ int32 grade = 1; // 等级:1/2/3/4/5...
+ repeated AssetItem items = 2;
+ int32 total_count = 3;
+ bool has_more = 4;
+}
+
+// 资产项
+message AssetItem {
+ int64 asset_id = 1;
+ string name = 2;
+ string cover_url_signed = 3; // 预签名封面URL
+ int32 like_count = 4;
+ int64 created_at = 5;
+ string category = 6; // regular: 'castlove' / collection: category / activity: activity_type
+ int32 grade = 7; // 仅 regular 时有效(1/2/3...),其他类型为 0
+}
+
+// ==================== 藏品列表(分页) ====================
+
+// 藏品列表请求
+message GetStarbookItemsRequest {
+ string type = 1; // 'regular' / 'collection' / 'activity'
+ string category = 2; // regular 时固定传 'castlove'
+ int32 grade = 3; // 仅 regular 时有效(1/2/3...)
+ int32 page = 4; // 默认 1
+ int32 page_size = 5; // 默认 20
+}
+
+// 藏品列表响应
+message GetStarbookItemsResponse {
+ topfans.common.BaseResponse base = 1;
+ AssetListData data = 2;
+}
+
+// 藏品列表数据
+message AssetListData {
+ repeated AssetItem items = 1;
+ int64 total = 2;
+ int32 page = 3;
+ int32 page_size = 4;
+ bool has_more = 5;
+}
diff --git a/backend/scripts/migrate_create_collection_activity_registry_tables.sql b/backend/scripts/migrate_create_collection_activity_registry_tables.sql
new file mode 100644
index 0000000..b339c0a
--- /dev/null
+++ b/backend/scripts/migrate_create_collection_activity_registry_tables.sql
@@ -0,0 +1,108 @@
+-- Starbook 典藏/活动藏品体系重构 - 数据库迁移
+-- 创建时间: 2026-04-17
+-- 状态: 新建(非破坏性变更)
+
+-- =============================================
+-- 1. 典藏藏品表 collection_assets
+-- =============================================
+CREATE TABLE IF NOT EXISTS collection_assets (
+ id BIGSERIAL PRIMARY KEY,
+ asset_id BIGINT UNIQUE NOT NULL,
+ owner_uid BIGINT NOT NULL,
+ star_id BIGINT NOT NULL,
+ name VARCHAR(100) NOT NULL,
+ cover_url VARCHAR(500) NOT NULL,
+ category VARCHAR(50) NOT NULL,
+ like_count INT NOT NULL DEFAULT 0,
+ status SMALLINT NOT NULL DEFAULT 0,
+ metadata JSONB,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+
+ CONSTRAINT uk_collection_owner_star_name UNIQUE (owner_uid, star_id, name)
+);
+
+CREATE INDEX IF NOT EXISTS idx_collection_owner_star ON collection_assets (owner_uid, star_id);
+CREATE INDEX IF NOT EXISTS idx_collection_category ON collection_assets (category);
+CREATE INDEX IF NOT EXISTS idx_collection_asset_id ON collection_assets (asset_id);
+
+-- =============================================
+-- 2. 活动藏品表 activity_assets
+-- =============================================
+CREATE TABLE IF NOT EXISTS activity_assets (
+ id BIGSERIAL PRIMARY KEY,
+ asset_id BIGINT UNIQUE NOT NULL,
+ owner_uid BIGINT NOT NULL,
+ star_id BIGINT NOT NULL,
+ activity_id BIGINT NOT NULL,
+ activity_type VARCHAR(50) NOT NULL,
+ name VARCHAR(100) NOT NULL,
+ cover_url VARCHAR(500) NOT NULL,
+ like_count INT NOT NULL DEFAULT 0,
+ status SMALLINT NOT NULL DEFAULT 0,
+ metadata JSONB,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+
+ CONSTRAINT uk_activity_owner_activity_name UNIQUE (owner_uid, activity_id, name)
+);
+
+CREATE INDEX IF NOT EXISTS idx_activity_owner ON activity_assets (owner_uid, activity_id);
+CREATE INDEX IF NOT EXISTS idx_activity_star ON activity_assets (star_id, activity_id);
+CREATE INDEX IF NOT EXISTS idx_activity_type ON activity_assets (activity_type);
+CREATE INDEX IF NOT EXISTS idx_activity_asset_id ON activity_assets (asset_id);
+
+-- =============================================
+-- 3. 统一索引表 asset_registry
+-- =============================================
+CREATE TABLE IF NOT EXISTS asset_registry (
+ id BIGSERIAL PRIMARY KEY,
+ asset_id BIGINT NOT NULL,
+ asset_type VARCHAR(20) NOT NULL,
+ owner_uid BIGINT NOT NULL,
+ star_id BIGINT NOT NULL,
+ -- 普通藏品专属字段(其他类型时为 NULL)
+ grade SMALLINT,
+ -- 典藏专属字段(其他类型时为 NULL)
+ collection_category VARCHAR(50),
+ -- 活动专属字段(其他类型时为 NULL)
+ activity_id BIGINT,
+ activity_type VARCHAR(50),
+ -- 公共字段
+ status SMALLINT NOT NULL DEFAULT 0,
+ like_count INT NOT NULL DEFAULT 0,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+
+ CONSTRAINT uk_registry_asset_type_id UNIQUE (asset_type, asset_id),
+ CONSTRAINT uk_registry_owner_star_type_asset UNIQUE (owner_uid, star_id, asset_type, asset_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_registry_owner_star ON asset_registry (owner_uid, star_id);
+CREATE INDEX IF NOT EXISTS idx_registry_type_star ON asset_registry (asset_type, star_id);
+CREATE INDEX IF NOT EXISTS idx_registry_star_grade ON asset_registry (star_id, grade) WHERE asset_type = 'regular';
+CREATE INDEX IF NOT EXISTS idx_registry_star_activity ON asset_registry (star_id, activity_id) WHERE asset_type = 'activity';
+
+-- =============================================
+-- 注释说明
+-- =============================================
+COMMENT ON TABLE collection_assets IS '典藏藏品表';
+COMMENT ON TABLE activity_assets IS '活动藏品表';
+COMMENT ON TABLE asset_registry IS '资产统一索引表';
+
+COMMENT ON COLUMN collection_assets.category IS '典藏子分类(如 limited, classic)';
+COMMENT ON COLUMN activity_assets.activity_type IS '活动类型(如 birthday, anniversary, concert)';
+COMMENT ON COLUMN asset_registry.asset_type IS '资产类型:regular(普通) | collection(典藏) | activity(活动)';
+COMMENT ON COLUMN asset_registry.grade IS '普通藏品等级:1/2/3...(仅 regular 类型有效)';
+
+-- =============================================
+-- 4. 修改 assets 表:将 rarity 改名为 grade
+-- =============================================
+-- 注意:如果 assets.rarity 字段不存在(新建数据库),则跳过此步
+DO $$
+BEGIN
+ IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'assets' AND column_name = 'rarity') THEN
+ ALTER TABLE assets RENAME COLUMN rarity TO grade;
+ COMMENT ON COLUMN assets.grade IS '星册等级:1/2/3...(铸爱流程创建时默认值为1)';
+ END IF;
+END $$;
diff --git a/backend/services/assetService/main.go b/backend/services/assetService/main.go
index 02d486e..18549bb 100644
--- a/backend/services/assetService/main.go
+++ b/backend/services/assetService/main.go
@@ -26,6 +26,7 @@ import (
"github.com/topfans/backend/services/assetService/provider"
"github.com/topfans/backend/services/assetService/repository"
"github.com/topfans/backend/services/assetService/service"
+ starbookRepo "github.com/topfans/backend/services/starbookService/repository"
)
var (
@@ -129,7 +130,8 @@ func main() {
// 创建 Service 层实例
assetService := service.NewAssetService(assetRepo, mintOrderRepo, assetLikeRepo, userClient, database.GetDB())
- mintService := service.NewMintService(assetRepo, mintOrderRepo, userClient, database.GetDB(), config.GlobalAssetConfig)
+ registryRepo := starbookRepo.NewAssetRegistryRepository(database.GetDB())
+ mintService := service.NewMintService(assetRepo, mintOrderRepo, userClient, database.GetDB(), config.GlobalAssetConfig, registryRepo)
assetLikeService := service.NewAssetLikeService(assetRepo, assetLikeRepo, database.GetDB())
rankingService := service.NewRankingService(rankingRepo, userClient)
logger.Logger.Info("Service layer initialized")
diff --git a/backend/services/starbookService/main.go b/backend/services/starbookService/main.go
new file mode 100644
index 0000000..edffda0
--- /dev/null
+++ b/backend/services/starbookService/main.go
@@ -0,0 +1,203 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "os/signal"
+ "strconv"
+ "syscall"
+
+ _ "dubbo.apache.org/dubbo-go/v3/imports"
+ "dubbo.apache.org/dubbo-go/v3/protocol"
+ "dubbo.apache.org/dubbo-go/v3/server"
+
+ "github.com/topfans/backend/pkg/database"
+ "github.com/topfans/backend/pkg/health"
+ "github.com/topfans/backend/pkg/logger"
+ "github.com/topfans/backend/pkg/models"
+ pb "github.com/topfans/backend/pkg/proto/starbook"
+ assetRepo "github.com/topfans/backend/services/assetService/repository"
+ "github.com/topfans/backend/services/starbookService/provider"
+ "github.com/topfans/backend/services/starbookService/repository"
+ "github.com/topfans/backend/services/starbookService/service"
+)
+
+var (
+ port = flag.Int("port", getEnvInt("PORT", 20005), "Dubbo service port")
+ dbHost = flag.String("db-host", getEnv("DB_HOST", "localhost"), "Database host")
+ dbPort = flag.Int("db-port", getEnvInt("DB_PORT", 5432), "Database port")
+ dbUser = flag.String("db-user", getEnv("DB_USER", "postgres"), "Database user")
+ dbPassword = flag.String("db-password", getEnv("DB_PASSWORD", ""), "Database password")
+ dbName = flag.String("db-name", getEnv("DB_NAME", "top-fans"), "Database name")
+ healthHandler *health.Handler
+)
+
+func getEnv(key, fallback string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return fallback
+}
+
+func getEnvInt(key string, fallback int) int {
+ if v := os.Getenv(key); v != "" {
+ if n, err := strconv.Atoi(v); err == nil {
+ return n
+ }
+ }
+ return fallback
+}
+
+func main() {
+ flag.Parse()
+
+ // 初始化日志(必须在最前面)
+ env := os.Getenv("ENV")
+ if env == "" {
+ env = "development"
+ }
+
+ if err := logger.Init(logger.Config{
+ ServiceName: "starbook-service",
+ Environment: env,
+ LogLevel: os.Getenv("LOG_LEVEL"),
+ }); err != nil {
+ panic(fmt.Sprintf("Failed to initialize logger: %v", err))
+ }
+ defer logger.Sync()
+
+ logger.Sugar.Info("Starting Starbook Service...")
+
+ // 初始化数据库
+ if err := initDatabase(); err != nil {
+ logger.Sugar.Fatalf("Failed to initialize database: %v", err)
+ }
+
+ // 自动迁移数据库表
+ if err := autoMigrate(); err != nil {
+ logger.Sugar.Fatalf("Failed to migrate database: %v", err)
+ }
+
+ // 初始化 Repository
+ registryRepo := repository.NewAssetRegistryRepository(database.GetDB())
+ collectionRepo := repository.NewCollectionRepository(database.GetDB())
+ activityRepo := repository.NewActivityAssetRepository(database.GetDB())
+ assetRepository := assetRepo.NewAssetRepository(database.GetDB())
+
+ // 初始化 Service
+ starbookService := service.NewStarbookService(
+ database.GetDB(),
+ registryRepo,
+ assetRepository,
+ collectionRepo,
+ activityRepo,
+ )
+
+ // 初始化 Provider
+ starbookProvider := provider.NewStarbookProvider(starbookService)
+
+ // 初始化 Dubbo-go 服务器
+ if err := initDubboService(starbookProvider); err != nil {
+ logger.Sugar.Fatalf("Failed to initialize Dubbo service: %v", err)
+ }
+
+ // 等待信号(优雅关闭)
+ logger.Sugar.Info("Starbook service started successfully. Press Ctrl+C to exit.")
+ gracefulShutdown()
+}
+
+// initDatabase 初始化数据库连接
+func initDatabase() error {
+ config := database.Config{
+ Host: *dbHost,
+ Port: *dbPort,
+ User: *dbUser,
+ Password: *dbPassword,
+ DBName: *dbName,
+ SSLMode: "disable",
+ TimeZone: "Asia/Shanghai",
+ }
+
+ return database.Init(config)
+}
+
+// autoMigrate 自动迁移数据库表
+func autoMigrate() error {
+ db := database.GetDB()
+ if db == nil {
+ return fmt.Errorf("database is not initialized")
+ }
+
+ // 迁移星册相关的表
+ tables := []interface{}{
+ &models.CollectionAsset{},
+ &models.ActivityAsset{},
+ &models.AssetRegistry{},
+ }
+
+ for _, table := range tables {
+ if err := db.AutoMigrate(table); err != nil {
+ return fmt.Errorf("failed to migrate table: %w", err)
+ }
+ }
+
+ logger.Sugar.Info("Database migration completed successfully")
+ return nil
+}
+
+// initDubboService 初始化 Dubbo 服务
+func initDubboService(starbookProvider *provider.StarbookProvider) error {
+ // 启动健康检查 HTTP 服务器
+ healthPort := *port + 1000 // e.g., 20005 -> 21005
+ healthHandler = health.NewHandler("starbook-service", healthPort)
+ healthHandler.Start()
+
+ // 创建 Dubbo Server
+ srv, err := server.NewServer(
+ server.WithServerProtocol(
+ protocol.WithPort(*port),
+ protocol.WithTriple(),
+ ),
+ )
+ if err != nil {
+ return fmt.Errorf("failed to create Dubbo server: %w", err)
+ }
+
+ // 注册服务
+ if err := pb.RegisterStarbookServiceHandler(srv, starbookProvider); err != nil {
+ return fmt.Errorf("failed to register StarbookService handler: %w", err)
+ }
+
+ logger.Sugar.Infof("Dubbo-go provider registered successfully, service: topfans.starbook.StarbookService, port: %d", *port)
+
+ // 在后台启动 Dubbo 服务器
+ go func() {
+ if err := srv.Serve(); err != nil {
+ logger.Sugar.Fatalf("Failed to serve Dubbo: %v", err)
+ }
+ }()
+
+ return nil
+}
+
+// gracefulShutdown 优雅关闭
+func gracefulShutdown() {
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+ <-quit
+
+ logger.Sugar.Info("Shutting down Starbook Service...")
+
+ // 关闭健康检查服务器
+ if healthHandler != nil {
+ healthHandler.Stop()
+ }
+
+ // 关闭数据库连接
+ if err := database.Close(); err != nil {
+ logger.Sugar.Errorf("Error closing database: %v", err)
+ }
+
+ logger.Sugar.Info("Starbook Service stopped")
+}
diff --git a/backend/services/starbookService/provider/starbook_provider.go b/backend/services/starbookService/provider/starbook_provider.go
new file mode 100644
index 0000000..2e24245
--- /dev/null
+++ b/backend/services/starbookService/provider/starbook_provider.go
@@ -0,0 +1,221 @@
+package provider
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "time"
+
+ "dubbo.apache.org/dubbo-go/v3/common/constant"
+ "github.com/topfans/backend/pkg/logger"
+ pb "github.com/topfans/backend/pkg/proto/starbook"
+ pbCommon "github.com/topfans/backend/pkg/proto/common"
+ "github.com/topfans/backend/services/starbookService/service"
+ "go.uber.org/zap"
+)
+
+// StarbookProvider 星册服务Provider实现
+// 实现 Triple 协议生成的 StarbookServiceHandler 接口
+type StarbookProvider struct {
+ starbookService service.StarbookService
+}
+
+// 确保 StarbookProvider 实现了 StarbookServiceHandler 接口
+var _ pb.StarbookServiceHandler = (*StarbookProvider)(nil)
+
+// NewStarbookProvider 创建星册服务Provider实例
+func NewStarbookProvider(starbookService service.StarbookService) *StarbookProvider {
+ return &StarbookProvider{
+ starbookService: starbookService,
+ }
+}
+
+// GetStarbookHome 获取星册首页
+func (p *StarbookProvider) GetStarbookHome(ctx context.Context, req *pb.GetStarbookHomeRequest) (*pb.GetStarbookHomeResponse, error) {
+ userID, starID, err := extractUserInfoFromDubboAttachments(ctx)
+ if err != nil {
+ return &pb.GetStarbookHomeResponse{
+ Base: &pbCommon.BaseResponse{
+ Code: pbCommon.StatusCode_STATUS_UNAUTHORIZED,
+ Message: "user authentication required",
+ Timestamp: 0,
+ },
+ }, err
+ }
+
+ resp, err := p.starbookService.GetStarbookHome(userID, starID)
+ if err != nil {
+ logger.Logger.Error("GetStarbookHome failed",
+ zap.Error(err),
+ )
+ return &pb.GetStarbookHomeResponse{
+ Base: &pbCommon.BaseResponse{
+ Code: toStatusCode(err),
+ Message: err.Error(),
+ Timestamp: time.Now().UnixMilli(),
+ },
+ }, err
+ }
+
+ return resp, nil
+}
+
+// GetStarbookItems 获取星册藏品列表
+func (p *StarbookProvider) GetStarbookItems(ctx context.Context, req *pb.GetStarbookItemsRequest) (*pb.GetStarbookItemsResponse, error) {
+ userID, starID, err := extractUserInfoFromDubboAttachments(ctx)
+ if err != nil {
+ return &pb.GetStarbookItemsResponse{
+ Base: &pbCommon.BaseResponse{
+ Code: pbCommon.StatusCode_STATUS_UNAUTHORIZED,
+ Message: "user authentication required",
+ Timestamp: 0,
+ },
+ }, err
+ }
+
+ resp, err := p.starbookService.GetStarbookItems(req, userID, starID)
+ if err != nil {
+ logger.Logger.Error("GetStarbookItems failed",
+ zap.Error(err),
+ )
+ return &pb.GetStarbookItemsResponse{
+ Base: &pbCommon.BaseResponse{
+ Code: toStatusCode(err),
+ Message: err.Error(),
+ Timestamp: time.Now().UnixMilli(),
+ },
+ }, err
+ }
+
+ return resp, nil
+}
+
+// extractUserInfoFromDubboAttachments 从 Dubbo attachments 中提取用户信息
+// 网关调用:网关已验证 Token 并将 user_id 和 star_id 通过 attachments 传递
+func extractUserInfoFromDubboAttachments(ctx context.Context) (int64, int64, error) {
+ logger.Logger.Info("=== extractUserInfoFromDubboAttachments called ===")
+
+ // 使用 constant.AttachmentKey 获取 Dubbo attachments
+ attachments := ctx.Value(constant.AttachmentKey)
+ logger.Logger.Info("ctx.Value(constant.AttachmentKey) result",
+ zap.Any("attachments", attachments),
+ zap.String("type", fmt.Sprintf("%T", attachments)),
+ )
+
+ if attachments != nil {
+ logger.Logger.Info("Attachments found in context")
+
+ if attMap, ok := attachments.(map[string]interface{}); ok {
+ logger.Logger.Info("Attachments is map[string]interface{}",
+ zap.Int("map_size", len(attMap)))
+ userID, starID := extractUserInfoFromMap(attMap)
+ if userID > 0 && starID > 0 {
+ logger.Logger.Info("Successfully extracted user info",
+ zap.Int64("user_id", userID),
+ zap.Int64("star_id", starID),
+ )
+ return userID, starID, nil
+ }
+ logger.Logger.Warn("Extracted zero user_id or star_id",
+ zap.Int64("user_id", userID),
+ zap.Int64("star_id", starID),
+ )
+ } else {
+ logger.Logger.Warn("Attachments is not map[string]interface{}",
+ zap.String("actual_type", fmt.Sprintf("%T", attachments)),
+ zap.Any("value", attachments),
+ )
+ }
+ } else {
+ logger.Logger.Warn("No attachments found in context")
+ // 尝试打印所有 context 的值
+ logger.Logger.Warn("Context type", zap.String("type", fmt.Sprintf("%T", ctx)))
+ }
+
+ return 0, 0, fmt.Errorf("user info not found in Dubbo attachments (expected user_id and star_id from gateway)")
+}
+
+// getContextKeys 获取 context 中所有的 key
+func getContextKeys(ctx context.Context) []string {
+ keys := make([]string, 0)
+ // 直接打印 ctx 类型
+ logger.Logger.Debug("Context type", zap.String("type", fmt.Sprintf("%T", ctx)))
+ return keys
+}
+
+// getContextValues 遍历 context 中的一些已知值
+func getContextValues(ctx context.Context) map[string]interface{} {
+ result := make(map[string]interface{})
+ // 检查 constant.AttachmentKey
+ if v := ctx.Value(constant.AttachmentKey); v != nil {
+ result["AttachmentKey"] = v
+ }
+ return result
+}
+
+// extractUserInfoFromMap 从 map 中提取 user_id 和 star_id
+// 支持多种类型:int64, float64, string
+func extractUserInfoFromMap(attMap map[string]interface{}) (int64, int64) {
+ var userID, starID int64
+
+ // 打印所有 key 和 value
+ for k, v := range attMap {
+ logger.Logger.Debug("Attachment map entry",
+ zap.String("key", k),
+ zap.Any("value", v),
+ zap.String("type", fmt.Sprintf("%T", v)),
+ )
+ }
+
+ // 提取 user_id
+ if v, ok := attMap["user_id"]; ok {
+ userID = parseIntValue(v)
+ }
+
+ // 提取 star_id
+ if v, ok := attMap["star_id"]; ok {
+ starID = parseIntValue(v)
+ }
+
+ return userID, starID
+}
+
+// parseIntValue 解析不同类型的整数值
+func parseIntValue(v interface{}) int64 {
+ switch val := v.(type) {
+ case int64:
+ return val
+ case int:
+ return int64(val)
+ case float64:
+ return int64(val)
+ case string:
+ if i, err := strconv.ParseInt(val, 10, 64); err == nil {
+ return i
+ }
+ case []string:
+ if len(val) > 0 {
+ if i, err := strconv.ParseInt(val[0], 10, 64); err == nil {
+ return i
+ }
+ }
+ case []interface{}:
+ if len(val) > 0 {
+ if s, ok := val[0].(string); ok {
+ if i, err := strconv.ParseInt(s, 10, 64); err == nil {
+ return i
+ }
+ }
+ }
+ }
+ return 0
+}
+
+// toStatusCode 将错误转换为Proto状态码
+func toStatusCode(err error) pbCommon.StatusCode {
+ if err == nil {
+ return pbCommon.StatusCode_STATUS_OK
+ }
+ // 简化处理,实际应该使用 appErrors.ToStatusCode
+ return pbCommon.StatusCode_STATUS_INTERNAL_ERROR
+}
diff --git a/backend/services/starbookService/repository/activity_asset_repository.go b/backend/services/starbookService/repository/activity_asset_repository.go
new file mode 100644
index 0000000..9e9c561
--- /dev/null
+++ b/backend/services/starbookService/repository/activity_asset_repository.go
@@ -0,0 +1,232 @@
+package repository
+
+import (
+ "errors"
+
+ appErrors "github.com/topfans/backend/pkg/errors"
+ "github.com/topfans/backend/pkg/models"
+ "gorm.io/gorm"
+)
+
+// ActivityAssetRepository 活动藏品Repository接口
+type ActivityAssetRepository interface {
+ // Create 创建活动藏品
+ Create(asset *models.ActivityAsset) error
+
+ // GetByID 根据ID查询
+ GetByID(id int64) (*models.ActivityAsset, error)
+
+ // GetByAssetID 根据asset_id查询
+ GetByAssetID(assetID int64) (*models.ActivityAsset, error)
+
+ // GetByOwner 查询用户的活动藏品列表
+ GetByOwner(ownerUID, starID int64, limit, offset int) ([]*models.ActivityAsset, error)
+
+ // GetByOwnerAndActivityType 查询用户指定活动类型的活动藏品
+ GetByOwnerAndActivityType(ownerUID, starID int64, activityType string, limit, offset int) ([]*models.ActivityAsset, error)
+
+ // GetByOwnerAndActivityID 查询用户指定活动的活动藏品
+ GetByOwnerAndActivityID(ownerUID, starID int64, activityID int64, limit, offset int) ([]*models.ActivityAsset, error)
+
+ // CountByOwner 统计用户的活动藏品数量
+ CountByOwner(ownerUID, starID int64) (int64, error)
+
+ // CountByOwnerAndActivityType 统计用户指定活动类型的活动藏品数量
+ CountByOwnerAndActivityType(ownerUID, starID int64, activityType string) (int64, error)
+
+ // UpdateLikeCount 更新点赞数
+ UpdateLikeCount(id int64, likeCount int32) error
+
+ // IncrementLikeCount 增加点赞数
+ IncrementLikeCount(id int64) error
+
+ // DecrementLikeCount 减少点赞数
+ DecrementLikeCount(id int64) error
+}
+
+// activityAssetRepository 活动藏品Repository实现
+type activityAssetRepository struct {
+ db *gorm.DB
+}
+
+// NewActivityAssetRepository 创建活动藏品Repository实例
+func NewActivityAssetRepository(db *gorm.DB) ActivityAssetRepository {
+ return &activityAssetRepository{db: db}
+}
+
+// Create 创建活动藏品
+func (r *activityAssetRepository) Create(asset *models.ActivityAsset) error {
+ if asset == nil {
+ return errors.New("activity asset cannot be nil")
+ }
+ if asset.OwnerUID <= 0 {
+ return errors.New("owner_uid must be greater than 0")
+ }
+ if asset.StarID <= 0 {
+ return errors.New("star_id must be greater than 0")
+ }
+ return r.db.Create(asset).Error
+}
+
+// GetByID 根据ID查询
+func (r *activityAssetRepository) GetByID(id int64) (*models.ActivityAsset, error) {
+ if id <= 0 {
+ return nil, errors.New("id must be greater than 0")
+ }
+ var asset models.ActivityAsset
+ if err := r.db.Where("id = ?", id).First(&asset).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, appErrors.ErrActivityAssetNotFound
+ }
+ return nil, err
+ }
+ return &asset, nil
+}
+
+// GetByAssetID 根据asset_id查询
+func (r *activityAssetRepository) GetByAssetID(assetID int64) (*models.ActivityAsset, error) {
+ if assetID <= 0 {
+ return nil, errors.New("asset_id must be greater than 0")
+ }
+ var asset models.ActivityAsset
+ if err := r.db.Where("asset_id = ?", assetID).First(&asset).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, appErrors.ErrActivityAssetNotFound
+ }
+ return nil, err
+ }
+ return &asset, nil
+}
+
+// GetByOwner 查询用户的活动藏品列表
+func (r *activityAssetRepository) GetByOwner(ownerUID, starID int64, limit, offset int) ([]*models.ActivityAsset, error) {
+ if ownerUID <= 0 {
+ return nil, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return nil, errors.New("star_id must be greater than 0")
+ }
+ var assets []*models.ActivityAsset
+ query := r.db.Where("owner_uid = ? AND star_id = ?", ownerUID, starID).
+ Order("created_at DESC")
+ if limit > 0 {
+ query = query.Limit(limit)
+ }
+ if offset > 0 {
+ query = query.Offset(offset)
+ }
+ if err := query.Find(&assets).Error; err != nil {
+ return nil, err
+ }
+ return assets, nil
+}
+
+// GetByOwnerAndActivityType 查询用户指定活动类型的活动藏品
+func (r *activityAssetRepository) GetByOwnerAndActivityType(ownerUID, starID int64, activityType string, limit, offset int) ([]*models.ActivityAsset, error) {
+ if ownerUID <= 0 {
+ return nil, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return nil, errors.New("star_id must be greater than 0")
+ }
+ var assets []*models.ActivityAsset
+ query := r.db.Where("owner_uid = ? AND star_id = ? AND activity_type = ?", ownerUID, starID, activityType).
+ Order("created_at DESC")
+ if limit > 0 {
+ query = query.Limit(limit)
+ }
+ if offset > 0 {
+ query = query.Offset(offset)
+ }
+ if err := query.Find(&assets).Error; err != nil {
+ return nil, err
+ }
+ return assets, nil
+}
+
+// GetByOwnerAndActivityID 查询用户指定活动的活动藏品
+func (r *activityAssetRepository) GetByOwnerAndActivityID(ownerUID, starID int64, activityID int64, limit, offset int) ([]*models.ActivityAsset, error) {
+ if ownerUID <= 0 {
+ return nil, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return nil, errors.New("star_id must be greater than 0")
+ }
+ var assets []*models.ActivityAsset
+ query := r.db.Where("owner_uid = ? AND star_id = ? AND activity_id = ?", ownerUID, starID, activityID).
+ Order("created_at DESC")
+ if limit > 0 {
+ query = query.Limit(limit)
+ }
+ if offset > 0 {
+ query = query.Offset(offset)
+ }
+ if err := query.Find(&assets).Error; err != nil {
+ return nil, err
+ }
+ return assets, nil
+}
+
+// CountByOwner 统计用户的活动藏品数量
+func (r *activityAssetRepository) CountByOwner(ownerUID, starID int64) (int64, error) {
+ if ownerUID <= 0 {
+ return 0, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return 0, errors.New("star_id must be greater than 0")
+ }
+ var count int64
+ if err := r.db.Model(&models.ActivityAsset{}).
+ Where("owner_uid = ? AND star_id = ?", ownerUID, starID).
+ Count(&count).Error; err != nil {
+ return 0, err
+ }
+ return count, nil
+}
+
+// CountByOwnerAndActivityType 统计用户指定活动类型的活动藏品数量
+func (r *activityAssetRepository) CountByOwnerAndActivityType(ownerUID, starID int64, activityType string) (int64, error) {
+ if ownerUID <= 0 {
+ return 0, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return 0, errors.New("star_id must be greater than 0")
+ }
+ var count int64
+ if err := r.db.Model(&models.ActivityAsset{}).
+ Where("owner_uid = ? AND star_id = ? AND activity_type = ?", ownerUID, starID, activityType).
+ Count(&count).Error; err != nil {
+ return 0, err
+ }
+ return count, nil
+}
+
+// UpdateLikeCount 更新点赞数
+func (r *activityAssetRepository) UpdateLikeCount(id int64, likeCount int32) error {
+ if id <= 0 {
+ return errors.New("id must be greater than 0")
+ }
+ return r.db.Model(&models.ActivityAsset{}).
+ Where("id = ?", id).
+ Update("like_count", likeCount).Error
+}
+
+// IncrementLikeCount 增加点赞数
+func (r *activityAssetRepository) IncrementLikeCount(id int64) error {
+ if id <= 0 {
+ return errors.New("id must be greater than 0")
+ }
+ return r.db.Model(&models.ActivityAsset{}).
+ Where("id = ?", id).
+ UpdateColumn("like_count", gorm.Expr("like_count + ?", 1)).Error
+}
+
+// DecrementLikeCount 减少点赞数
+func (r *activityAssetRepository) DecrementLikeCount(id int64) error {
+ if id <= 0 {
+ return errors.New("id must be greater than 0")
+ }
+ return r.db.Model(&models.ActivityAsset{}).
+ Where("id = ? AND like_count > ?", id, 0).
+ UpdateColumn("like_count", gorm.Expr("like_count - ?", 1)).Error
+}
diff --git a/backend/services/starbookService/repository/asset_registry_repository.go b/backend/services/starbookService/repository/asset_registry_repository.go
new file mode 100644
index 0000000..16c25eb
--- /dev/null
+++ b/backend/services/starbookService/repository/asset_registry_repository.go
@@ -0,0 +1,372 @@
+package repository
+
+import (
+ "errors"
+
+ appErrors "github.com/topfans/backend/pkg/errors"
+ "github.com/topfans/backend/pkg/models"
+ "gorm.io/gorm"
+)
+
+// AssetRegistryRepository 资产统一索引Repository接口
+type AssetRegistryRepository interface {
+ // Create 创建索引记录
+ Create(registry *models.AssetRegistry) error
+
+ // GetByID 根据ID查询
+ GetByID(id int64) (*models.AssetRegistry, error)
+
+ // GetByAssetID 根据asset_id查询
+ GetByAssetID(assetID int64) (*models.AssetRegistry, error)
+
+ // GetByAssetTypeAndID 根据类型和asset_id查询
+ GetByAssetTypeAndID(assetType string, assetID int64) (*models.AssetRegistry, error)
+
+ // GetByOwner 查询用户的所有索引记录
+ GetByOwner(ownerUID, starID int64) ([]*models.AssetRegistry, error)
+
+ // GetByOwnerAndType 查询用户指定类型的索引记录
+ GetByOwnerAndType(ownerUID, starID int64, assetType string, limit, offset int) ([]*models.AssetRegistry, error)
+
+ // GetByOwnerAndTypeAndGrade 查询用户指定类型和等级的索引记录
+ GetByOwnerAndTypeAndGrade(ownerUID, starID int64, assetType string, grade int32, limit, offset int) ([]*models.AssetRegistry, error)
+
+ // GetByOwnerAndTypeAndCategory 查询用户指定类型和分类的索引记录
+ GetByOwnerAndTypeAndCategory(ownerUID, starID int64, assetType string, category string, limit, offset int) ([]*models.AssetRegistry, error)
+
+ // GetByOwnerAndTypeAndActivity 查询用户指定类型和活动的索引记录
+ GetByOwnerAndTypeAndActivity(ownerUID, starID int64, assetType string, activityID int64, limit, offset int) ([]*models.AssetRegistry, error)
+
+ // CountByOwner 统计用户的索引记录数量
+ CountByOwner(ownerUID, starID int64) (int64, error)
+
+ // CountByOwnerAndType 统计用户指定类型的索引记录数量
+ CountByOwnerAndType(ownerUID, starID int64, assetType string) (int64, error)
+
+ // CountByOwnerAndTypeAndGrade 统计用户指定类型和等级的索引记录数量
+ CountByOwnerAndTypeAndGrade(ownerUID, starID int64, assetType string, grade int32) (int64, error)
+
+ // CountByOwnerAndTypeAndCategory 统计用户指定类型和分类的索引记录数量
+ CountByOwnerAndTypeAndCategory(ownerUID, starID int64, assetType string, category string) (int64, error)
+
+ // CountByOwnerAndTypeAndActivity 统计用户指定类型和活动的索引记录数量
+ CountByOwnerAndTypeAndActivity(ownerUID, starID int64, assetType string, activityID int64) (int64, error)
+
+ // UpdateLikeCount 更新点赞数
+ UpdateLikeCount(assetID int64, likeCount int32) error
+
+ // UpdateGrade 更新等级
+ UpdateGrade(assetID int64, grade int32) error
+
+ // Delete 删除索引记录
+ Delete(assetID int64) error
+
+ // DeleteByAssetType 删除指定类型的索引记录
+ DeleteByAssetType(assetType string, assetID int64) error
+}
+
+// assetRegistryRepository 资产统一索引Repository实现
+type assetRegistryRepository struct {
+ db *gorm.DB
+}
+
+// NewAssetRegistryRepository 创建资产统一索引Repository实例
+func NewAssetRegistryRepository(db *gorm.DB) AssetRegistryRepository {
+ return &assetRegistryRepository{db: db}
+}
+
+// Create 创建索引记录
+func (r *assetRegistryRepository) Create(registry *models.AssetRegistry) error {
+ if registry == nil {
+ return errors.New("registry cannot be nil")
+ }
+ if registry.OwnerUID <= 0 {
+ return errors.New("owner_uid must be greater than 0")
+ }
+ if registry.StarID <= 0 {
+ return errors.New("star_id must be greater than 0")
+ }
+ return r.db.Create(registry).Error
+}
+
+// GetByID 根据ID查询
+func (r *assetRegistryRepository) GetByID(id int64) (*models.AssetRegistry, error) {
+ if id <= 0 {
+ return nil, errors.New("id must be greater than 0")
+ }
+ var registry models.AssetRegistry
+ if err := r.db.Where("id = ?", id).First(®istry).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, appErrors.ErrAssetRegistryNotFound
+ }
+ return nil, err
+ }
+ return ®istry, nil
+}
+
+// GetByAssetID 根据asset_id查询
+func (r *assetRegistryRepository) GetByAssetID(assetID int64) (*models.AssetRegistry, error) {
+ if assetID <= 0 {
+ return nil, errors.New("asset_id must be greater than 0")
+ }
+ var registry models.AssetRegistry
+ if err := r.db.Where("asset_id = ?", assetID).First(®istry).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, appErrors.ErrAssetRegistryNotFound
+ }
+ return nil, err
+ }
+ return ®istry, nil
+}
+
+// GetByAssetTypeAndID 根据类型和asset_id查询
+func (r *assetRegistryRepository) GetByAssetTypeAndID(assetType string, assetID int64) (*models.AssetRegistry, error) {
+ if assetType == "" {
+ return nil, errors.New("asset_type must not be empty")
+ }
+ if assetID <= 0 {
+ return nil, errors.New("asset_id must be greater than 0")
+ }
+ var registry models.AssetRegistry
+ if err := r.db.Where("asset_type = ? AND asset_id = ?", assetType, assetID).First(®istry).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, appErrors.ErrAssetRegistryNotFound
+ }
+ return nil, err
+ }
+ return ®istry, nil
+}
+
+// GetByOwner 查询用户的所有索引记录
+func (r *assetRegistryRepository) GetByOwner(ownerUID, starID int64) ([]*models.AssetRegistry, error) {
+ if ownerUID <= 0 {
+ return nil, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return nil, errors.New("star_id must be greater than 0")
+ }
+ var registries []*models.AssetRegistry
+ if err := r.db.Where("owner_uid = ? AND star_id = ?", ownerUID, starID).
+ Order("created_at DESC").
+ Find(®istries).Error; err != nil {
+ return nil, err
+ }
+ return registries, nil
+}
+
+// GetByOwnerAndType 查询用户指定类型的索引记录
+func (r *assetRegistryRepository) GetByOwnerAndType(ownerUID, starID int64, assetType string, limit, offset int) ([]*models.AssetRegistry, error) {
+ if ownerUID <= 0 {
+ return nil, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return nil, errors.New("star_id must be greater than 0")
+ }
+ var registries []*models.AssetRegistry
+ query := r.db.Where("owner_uid = ? AND star_id = ? AND asset_type = ?", ownerUID, starID, assetType).
+ Order("created_at DESC")
+ if limit > 0 {
+ query = query.Limit(limit)
+ }
+ if offset > 0 {
+ query = query.Offset(offset)
+ }
+ if err := query.Find(®istries).Error; err != nil {
+ return nil, err
+ }
+ return registries, nil
+}
+
+// GetByOwnerAndTypeAndGrade 查询用户指定类型和等级的索引记录
+func (r *assetRegistryRepository) GetByOwnerAndTypeAndGrade(ownerUID, starID int64, assetType string, grade int32, limit, offset int) ([]*models.AssetRegistry, error) {
+ if ownerUID <= 0 {
+ return nil, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return nil, errors.New("star_id must be greater than 0")
+ }
+ var registries []*models.AssetRegistry
+ query := r.db.Where("owner_uid = ? AND star_id = ? AND asset_type = ? AND grade = ?", ownerUID, starID, assetType, grade).
+ Order("created_at DESC")
+ if limit > 0 {
+ query = query.Limit(limit)
+ }
+ if offset > 0 {
+ query = query.Offset(offset)
+ }
+ if err := query.Find(®istries).Error; err != nil {
+ return nil, err
+ }
+ return registries, nil
+}
+
+// GetByOwnerAndTypeAndCategory 查询用户指定类型和分类的索引记录
+func (r *assetRegistryRepository) GetByOwnerAndTypeAndCategory(ownerUID, starID int64, assetType string, category string, limit, offset int) ([]*models.AssetRegistry, error) {
+ if ownerUID <= 0 {
+ return nil, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return nil, errors.New("star_id must be greater than 0")
+ }
+ var registries []*models.AssetRegistry
+ query := r.db.Where("owner_uid = ? AND star_id = ? AND asset_type = ? AND collection_category = ?", ownerUID, starID, assetType, category).
+ Order("created_at DESC")
+ if limit > 0 {
+ query = query.Limit(limit)
+ }
+ if offset > 0 {
+ query = query.Offset(offset)
+ }
+ if err := query.Find(®istries).Error; err != nil {
+ return nil, err
+ }
+ return registries, nil
+}
+
+// GetByOwnerAndTypeAndActivity 查询用户指定类型和活动的索引记录
+func (r *assetRegistryRepository) GetByOwnerAndTypeAndActivity(ownerUID, starID int64, assetType string, activityID int64, limit, offset int) ([]*models.AssetRegistry, error) {
+ if ownerUID <= 0 {
+ return nil, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return nil, errors.New("star_id must be greater than 0")
+ }
+ var registries []*models.AssetRegistry
+ query := r.db.Where("owner_uid = ? AND star_id = ? AND asset_type = ? AND activity_id = ?", ownerUID, starID, assetType, activityID).
+ Order("created_at DESC")
+ if limit > 0 {
+ query = query.Limit(limit)
+ }
+ if offset > 0 {
+ query = query.Offset(offset)
+ }
+ if err := query.Find(®istries).Error; err != nil {
+ return nil, err
+ }
+ return registries, nil
+}
+
+// CountByOwner 统计用户的索引记录数量
+func (r *assetRegistryRepository) CountByOwner(ownerUID, starID int64) (int64, error) {
+ if ownerUID <= 0 {
+ return 0, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return 0, errors.New("star_id must be greater than 0")
+ }
+ var count int64
+ if err := r.db.Model(&models.AssetRegistry{}).
+ Where("owner_uid = ? AND star_id = ?", ownerUID, starID).
+ Count(&count).Error; err != nil {
+ return 0, err
+ }
+ return count, nil
+}
+
+// CountByOwnerAndType 统计用户指定类型的索引记录数量
+func (r *assetRegistryRepository) CountByOwnerAndType(ownerUID, starID int64, assetType string) (int64, error) {
+ if ownerUID <= 0 {
+ return 0, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return 0, errors.New("star_id must be greater than 0")
+ }
+ var count int64
+ if err := r.db.Model(&models.AssetRegistry{}).
+ Where("owner_uid = ? AND star_id = ? AND asset_type = ?", ownerUID, starID, assetType).
+ Count(&count).Error; err != nil {
+ return 0, err
+ }
+ return count, nil
+}
+
+// CountByOwnerAndTypeAndGrade 统计用户指定类型和等级的索引记录数量
+func (r *assetRegistryRepository) CountByOwnerAndTypeAndGrade(ownerUID, starID int64, assetType string, grade int32) (int64, error) {
+ if ownerUID <= 0 {
+ return 0, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return 0, errors.New("star_id must be greater than 0")
+ }
+ var count int64
+ if err := r.db.Model(&models.AssetRegistry{}).
+ Where("owner_uid = ? AND star_id = ? AND asset_type = ? AND grade = ?", ownerUID, starID, assetType, grade).
+ Count(&count).Error; err != nil {
+ return 0, err
+ }
+ return count, nil
+}
+
+// CountByOwnerAndTypeAndCategory 统计用户指定类型和分类的索引记录数量
+func (r *assetRegistryRepository) CountByOwnerAndTypeAndCategory(ownerUID, starID int64, assetType string, category string) (int64, error) {
+ if ownerUID <= 0 {
+ return 0, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return 0, errors.New("star_id must be greater than 0")
+ }
+ var count int64
+ if err := r.db.Model(&models.AssetRegistry{}).
+ Where("owner_uid = ? AND star_id = ? AND asset_type = ? AND collection_category = ?", ownerUID, starID, assetType, category).
+ Count(&count).Error; err != nil {
+ return 0, err
+ }
+ return count, nil
+}
+
+// CountByOwnerAndTypeAndActivity 统计用户指定类型和活动的索引记录数量
+func (r *assetRegistryRepository) CountByOwnerAndTypeAndActivity(ownerUID, starID int64, assetType string, activityID int64) (int64, error) {
+ if ownerUID <= 0 {
+ return 0, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return 0, errors.New("star_id must be greater than 0")
+ }
+ var count int64
+ if err := r.db.Model(&models.AssetRegistry{}).
+ Where("owner_uid = ? AND star_id = ? AND asset_type = ? AND activity_id = ?", ownerUID, starID, assetType, activityID).
+ Count(&count).Error; err != nil {
+ return 0, err
+ }
+ return count, nil
+}
+
+// UpdateLikeCount 更新点赞数
+func (r *assetRegistryRepository) UpdateLikeCount(assetID int64, likeCount int32) error {
+ if assetID <= 0 {
+ return errors.New("asset_id must be greater than 0")
+ }
+ return r.db.Model(&models.AssetRegistry{}).
+ Where("asset_id = ?", assetID).
+ Update("like_count", likeCount).Error
+}
+
+// UpdateGrade 更新等级
+func (r *assetRegistryRepository) UpdateGrade(assetID int64, grade int32) error {
+ if assetID <= 0 {
+ return errors.New("asset_id must be greater than 0")
+ }
+ return r.db.Model(&models.AssetRegistry{}).
+ Where("asset_id = ?", assetID).
+ Update("grade", grade).Error
+}
+
+// Delete 删除索引记录
+func (r *assetRegistryRepository) Delete(assetID int64) error {
+ if assetID <= 0 {
+ return errors.New("asset_id must be greater than 0")
+ }
+ return r.db.Where("asset_id = ?", assetID).Delete(&models.AssetRegistry{}).Error
+}
+
+// DeleteByAssetType 删除指定类型的索引记录
+func (r *assetRegistryRepository) DeleteByAssetType(assetType string, assetID int64) error {
+ if assetType == "" {
+ return errors.New("asset_type must not be empty")
+ }
+ if assetID <= 0 {
+ return errors.New("asset_id must be greater than 0")
+ }
+ return r.db.Where("asset_type = ? AND asset_id = ?", assetType, assetID).
+ Delete(&models.AssetRegistry{}).Error
+}
diff --git a/backend/services/starbookService/repository/collection_repository.go b/backend/services/starbookService/repository/collection_repository.go
new file mode 100644
index 0000000..f413216
--- /dev/null
+++ b/backend/services/starbookService/repository/collection_repository.go
@@ -0,0 +1,206 @@
+package repository
+
+import (
+ "errors"
+
+ appErrors "github.com/topfans/backend/pkg/errors"
+ "github.com/topfans/backend/pkg/models"
+ "gorm.io/gorm"
+)
+
+// CollectionRepository 典藏藏品Repository接口
+type CollectionRepository interface {
+ // Create 创建典藏藏品
+ Create(asset *models.CollectionAsset) error
+
+ // GetByID 根据ID查询
+ GetByID(id int64) (*models.CollectionAsset, error)
+
+ // GetByAssetID 根据asset_id查询
+ GetByAssetID(assetID int64) (*models.CollectionAsset, error)
+
+ // GetByOwner 查询用户的典藏藏品列表
+ GetByOwner(ownerUID, starID int64, limit, offset int) ([]*models.CollectionAsset, error)
+
+ // GetByOwnerAndCategory 查询用户指定分类的典藏藏品
+ GetByOwnerAndCategory(ownerUID, starID int64, category string, limit, offset int) ([]*models.CollectionAsset, error)
+
+ // CountByOwner 统计用户的典藏藏品数量
+ CountByOwner(ownerUID, starID int64) (int64, error)
+
+ // CountByOwnerAndCategory 统计用户指定分类的典藏藏品数量
+ CountByOwnerAndCategory(ownerUID, starID int64, category string) (int64, error)
+
+ // UpdateLikeCount 更新点赞数
+ UpdateLikeCount(id int64, likeCount int32) error
+
+ // IncrementLikeCount 增加点赞数
+ IncrementLikeCount(id int64) error
+
+ // DecrementLikeCount 减少点赞数
+ DecrementLikeCount(id int64) error
+}
+
+// collectionRepository 典藏藏品Repository实现
+type collectionRepository struct {
+ db *gorm.DB
+}
+
+// NewCollectionRepository 创建典藏藏品Repository实例
+func NewCollectionRepository(db *gorm.DB) CollectionRepository {
+ return &collectionRepository{db: db}
+}
+
+// Create 创建典藏藏品
+func (r *collectionRepository) Create(asset *models.CollectionAsset) error {
+ if asset == nil {
+ return errors.New("collection asset cannot be nil")
+ }
+ if asset.OwnerUID <= 0 {
+ return errors.New("owner_uid must be greater than 0")
+ }
+ if asset.StarID <= 0 {
+ return errors.New("star_id must be greater than 0")
+ }
+ return r.db.Create(asset).Error
+}
+
+// GetByID 根据ID查询
+func (r *collectionRepository) GetByID(id int64) (*models.CollectionAsset, error) {
+ if id <= 0 {
+ return nil, errors.New("id must be greater than 0")
+ }
+ var asset models.CollectionAsset
+ if err := r.db.Where("id = ?", id).First(&asset).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, appErrors.ErrCollectionAssetNotFound
+ }
+ return nil, err
+ }
+ return &asset, nil
+}
+
+// GetByAssetID 根据asset_id查询
+func (r *collectionRepository) GetByAssetID(assetID int64) (*models.CollectionAsset, error) {
+ if assetID <= 0 {
+ return nil, errors.New("asset_id must be greater than 0")
+ }
+ var asset models.CollectionAsset
+ if err := r.db.Where("asset_id = ?", assetID).First(&asset).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, appErrors.ErrCollectionAssetNotFound
+ }
+ return nil, err
+ }
+ return &asset, nil
+}
+
+// GetByOwner 查询用户的典藏藏品列表
+func (r *collectionRepository) GetByOwner(ownerUID, starID int64, limit, offset int) ([]*models.CollectionAsset, error) {
+ if ownerUID <= 0 {
+ return nil, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return nil, errors.New("star_id must be greater than 0")
+ }
+ var assets []*models.CollectionAsset
+ query := r.db.Where("owner_uid = ? AND star_id = ?", ownerUID, starID).
+ Order("created_at DESC")
+ if limit > 0 {
+ query = query.Limit(limit)
+ }
+ if offset > 0 {
+ query = query.Offset(offset)
+ }
+ if err := query.Find(&assets).Error; err != nil {
+ return nil, err
+ }
+ return assets, nil
+}
+
+// GetByOwnerAndCategory 查询用户指定分类的典藏藏品
+func (r *collectionRepository) GetByOwnerAndCategory(ownerUID, starID int64, category string, limit, offset int) ([]*models.CollectionAsset, error) {
+ if ownerUID <= 0 {
+ return nil, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return nil, errors.New("star_id must be greater than 0")
+ }
+ var assets []*models.CollectionAsset
+ query := r.db.Where("owner_uid = ? AND star_id = ? AND category = ?", ownerUID, starID, category).
+ Order("created_at DESC")
+ if limit > 0 {
+ query = query.Limit(limit)
+ }
+ if offset > 0 {
+ query = query.Offset(offset)
+ }
+ if err := query.Find(&assets).Error; err != nil {
+ return nil, err
+ }
+ return assets, nil
+}
+
+// CountByOwner 统计用户的典藏藏品数量
+func (r *collectionRepository) CountByOwner(ownerUID, starID int64) (int64, error) {
+ if ownerUID <= 0 {
+ return 0, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return 0, errors.New("star_id must be greater than 0")
+ }
+ var count int64
+ if err := r.db.Model(&models.CollectionAsset{}).
+ Where("owner_uid = ? AND star_id = ?", ownerUID, starID).
+ Count(&count).Error; err != nil {
+ return 0, err
+ }
+ return count, nil
+}
+
+// CountByOwnerAndCategory 统计用户指定分类的典藏藏品数量
+func (r *collectionRepository) CountByOwnerAndCategory(ownerUID, starID int64, category string) (int64, error) {
+ if ownerUID <= 0 {
+ return 0, errors.New("owner_uid must be greater than 0")
+ }
+ if starID <= 0 {
+ return 0, errors.New("star_id must be greater than 0")
+ }
+ var count int64
+ if err := r.db.Model(&models.CollectionAsset{}).
+ Where("owner_uid = ? AND star_id = ? AND category = ?", ownerUID, starID, category).
+ Count(&count).Error; err != nil {
+ return 0, err
+ }
+ return count, nil
+}
+
+// UpdateLikeCount 更新点赞数
+func (r *collectionRepository) UpdateLikeCount(id int64, likeCount int32) error {
+ if id <= 0 {
+ return errors.New("id must be greater than 0")
+ }
+ return r.db.Model(&models.CollectionAsset{}).
+ Where("id = ?", id).
+ Update("like_count", likeCount).Error
+}
+
+// IncrementLikeCount 增加点赞数
+func (r *collectionRepository) IncrementLikeCount(id int64) error {
+ if id <= 0 {
+ return errors.New("id must be greater than 0")
+ }
+ return r.db.Model(&models.CollectionAsset{}).
+ Where("id = ?", id).
+ UpdateColumn("like_count", gorm.Expr("like_count + ?", 1)).Error
+}
+
+// DecrementLikeCount 减少点赞数
+func (r *collectionRepository) DecrementLikeCount(id int64) error {
+ if id <= 0 {
+ return errors.New("id must be greater than 0")
+ }
+ return r.db.Model(&models.CollectionAsset{}).
+ Where("id = ? AND like_count > ?", id, 0).
+ UpdateColumn("like_count", gorm.Expr("like_count - ?", 1)).Error
+}
diff --git a/backend/services/starbookService/service/activity_asset_service.go b/backend/services/starbookService/service/activity_asset_service.go
new file mode 100644
index 0000000..31c9c42
--- /dev/null
+++ b/backend/services/starbookService/service/activity_asset_service.go
@@ -0,0 +1,132 @@
+package service
+
+import (
+ "time"
+
+ appErrors "github.com/topfans/backend/pkg/errors"
+ "github.com/topfans/backend/pkg/models"
+ "github.com/topfans/backend/services/starbookService/repository"
+)
+
+// ActivityAssetService 活动藏品服务接口
+type ActivityAssetService interface {
+ // Create 创建活动藏品
+ Create(asset *models.ActivityAsset) error
+
+ // GetByID 根据ID获取
+ GetByID(id int64) (*models.ActivityAsset, error)
+
+ // GetByAssetID 根据asset_id获取
+ GetByAssetID(assetID int64) (*models.ActivityAsset, error)
+
+ // GetByOwner 获取用户的活动藏品
+ GetByOwner(ownerUID, starID int64, limit, offset int) ([]*models.ActivityAsset, error)
+
+ // GetByOwnerAndActivityType 获取用户指定活动类型的活动藏品
+ GetByOwnerAndActivityType(ownerUID, starID int64, activityType string, limit, offset int) ([]*models.ActivityAsset, error)
+
+ // GetByOwnerAndActivityID 获取用户指定活动的活动藏品
+ GetByOwnerAndActivityID(ownerUID, starID int64, activityID int64, limit, offset int) ([]*models.ActivityAsset, error)
+
+ // CountByOwner 统计用户的活动藏品数量
+ CountByOwner(ownerUID, starID int64) (int64, error)
+
+ // CountByOwnerAndActivityType 统计用户指定活动类型的活动藏品数量
+ CountByOwnerAndActivityType(ownerUID, starID int64, activityType string) (int64, error)
+
+ // IncrementLikeCount 增加点赞数
+ IncrementLikeCount(id int64) error
+
+ // DecrementLikeCount 减少点赞数
+ DecrementLikeCount(id int64) error
+}
+
+// activityAssetService 活动藏品服务实现
+type activityAssetService struct {
+ activityRepo repository.ActivityAssetRepository
+ registryRepo repository.AssetRegistryRepository
+}
+
+// NewActivityAssetService 创建活动藏品服务实例
+func NewActivityAssetService(
+ activityRepo repository.ActivityAssetRepository,
+ registryRepo repository.AssetRegistryRepository,
+) ActivityAssetService {
+ return &activityAssetService{
+ activityRepo: activityRepo,
+ registryRepo: registryRepo,
+ }
+}
+
+// Create 创建活动藏品
+func (s *activityAssetService) Create(asset *models.ActivityAsset) error {
+ if asset == nil {
+ return appErrors.ErrInvalidAssetStatus
+ }
+
+ // 创建活动藏品记录
+ if err := s.activityRepo.Create(asset); err != nil {
+ return err
+ }
+
+ // 同步写入 asset_registry
+ registry := &models.AssetRegistry{
+ AssetID: asset.AssetID,
+ AssetType: models.AssetTypeActivity,
+ OwnerUID: asset.OwnerUID,
+ StarID: asset.StarID,
+ ActivityID: &asset.ActivityID,
+ ActivityType: &asset.ActivityType,
+ Status: asset.Status,
+ LikeCount: asset.LikeCount,
+ CreatedAt: time.Now().UnixMilli(),
+ UpdatedAt: time.Now().UnixMilli(),
+ }
+
+ return s.registryRepo.Create(registry)
+}
+
+// GetByID 根据ID获取
+func (s *activityAssetService) GetByID(id int64) (*models.ActivityAsset, error) {
+ return s.activityRepo.GetByID(id)
+}
+
+// GetByAssetID 根据asset_id获取
+func (s *activityAssetService) GetByAssetID(assetID int64) (*models.ActivityAsset, error) {
+ return s.activityRepo.GetByAssetID(assetID)
+}
+
+// GetByOwner 获取用户的活动藏品
+func (s *activityAssetService) GetByOwner(ownerUID, starID int64, limit, offset int) ([]*models.ActivityAsset, error) {
+ return s.activityRepo.GetByOwner(ownerUID, starID, limit, offset)
+}
+
+// GetByOwnerAndActivityType 获取用户指定活动类型的活动藏品
+func (s *activityAssetService) GetByOwnerAndActivityType(ownerUID, starID int64, activityType string, limit, offset int) ([]*models.ActivityAsset, error) {
+ return s.activityRepo.GetByOwnerAndActivityType(ownerUID, starID, activityType, limit, offset)
+}
+
+// GetByOwnerAndActivityID 获取用户指定活动的活动藏品
+func (s *activityAssetService) GetByOwnerAndActivityID(ownerUID, starID int64, activityID int64, limit, offset int) ([]*models.ActivityAsset, error) {
+ return s.activityRepo.GetByOwnerAndActivityID(ownerUID, starID, activityID, limit, offset)
+}
+
+// CountByOwner 统计用户的活动藏品数量
+func (s *activityAssetService) CountByOwner(ownerUID, starID int64) (int64, error) {
+ return s.activityRepo.CountByOwner(ownerUID, starID)
+}
+
+// CountByOwnerAndActivityType 统计用户指定活动类型的活动藏品数量
+func (s *activityAssetService) CountByOwnerAndActivityType(ownerUID, starID int64, activityType string) (int64, error) {
+ return s.activityRepo.CountByOwnerAndActivityType(ownerUID, starID, activityType)
+}
+
+// IncrementLikeCount 增加点赞数
+func (s *activityAssetService) IncrementLikeCount(id int64) error {
+ return s.activityRepo.IncrementLikeCount(id)
+}
+
+// DecrementLikeCount 减少点赞数
+func (s *activityAssetService) DecrementLikeCount(id int64) error {
+ return s.activityRepo.DecrementLikeCount(id)
+}
diff --git a/backend/services/starbookService/service/collection_service.go b/backend/services/starbookService/service/collection_service.go
new file mode 100644
index 0000000..92b43fc
--- /dev/null
+++ b/backend/services/starbookService/service/collection_service.go
@@ -0,0 +1,123 @@
+package service
+
+import (
+ "time"
+
+ appErrors "github.com/topfans/backend/pkg/errors"
+ "github.com/topfans/backend/pkg/models"
+ "github.com/topfans/backend/services/starbookService/repository"
+)
+
+// CollectionService 典藏服务接口
+type CollectionService interface {
+ // Create 创建典藏藏品
+ Create(asset *models.CollectionAsset) error
+
+ // GetByID 根据ID获取
+ GetByID(id int64) (*models.CollectionAsset, error)
+
+ // GetByAssetID 根据asset_id获取
+ GetByAssetID(assetID int64) (*models.CollectionAsset, error)
+
+ // GetByOwner 获取用户的典藏藏品
+ GetByOwner(ownerUID, starID int64, limit, offset int) ([]*models.CollectionAsset, error)
+
+ // GetByOwnerAndCategory 获取用户指定分类的典藏藏品
+ GetByOwnerAndCategory(ownerUID, starID int64, category string, limit, offset int) ([]*models.CollectionAsset, error)
+
+ // CountByOwner 统计用户的典藏藏品数量
+ CountByOwner(ownerUID, starID int64) (int64, error)
+
+ // CountByOwnerAndCategory 统计用户指定分类的典藏藏品数量
+ CountByOwnerAndCategory(ownerUID, starID int64, category string) (int64, error)
+
+ // IncrementLikeCount 增加点赞数
+ IncrementLikeCount(id int64) error
+
+ // DecrementLikeCount 减少点赞数
+ DecrementLikeCount(id int64) error
+}
+
+// collectionService 典藏服务实现
+type collectionService struct {
+ collectionRepo repository.CollectionRepository
+ registryRepo repository.AssetRegistryRepository
+}
+
+// NewCollectionService 创建典藏服务实例
+func NewCollectionService(
+ collectionRepo repository.CollectionRepository,
+ registryRepo repository.AssetRegistryRepository,
+) CollectionService {
+ return &collectionService{
+ collectionRepo: collectionRepo,
+ registryRepo: registryRepo,
+ }
+}
+
+// Create 创建典藏藏品
+func (s *collectionService) Create(asset *models.CollectionAsset) error {
+ if asset == nil {
+ return appErrors.ErrInvalidAssetStatus
+ }
+
+ // 创建典藏藏品记录
+ if err := s.collectionRepo.Create(asset); err != nil {
+ return err
+ }
+
+ // 同步写入 asset_registry
+ registry := &models.AssetRegistry{
+ AssetID: asset.AssetID,
+ AssetType: models.AssetTypeCollection,
+ OwnerUID: asset.OwnerUID,
+ StarID: asset.StarID,
+ CollectionCategory: &asset.Category,
+ Status: asset.Status,
+ LikeCount: asset.LikeCount,
+ CreatedAt: time.Now().UnixMilli(),
+ UpdatedAt: time.Now().UnixMilli(),
+ }
+
+ return s.registryRepo.Create(registry)
+}
+
+// GetByID 根据ID获取
+func (s *collectionService) GetByID(id int64) (*models.CollectionAsset, error) {
+ return s.collectionRepo.GetByID(id)
+}
+
+// GetByAssetID 根据asset_id获取
+func (s *collectionService) GetByAssetID(assetID int64) (*models.CollectionAsset, error) {
+ return s.collectionRepo.GetByAssetID(assetID)
+}
+
+// GetByOwner 获取用户的典藏藏品
+func (s *collectionService) GetByOwner(ownerUID, starID int64, limit, offset int) ([]*models.CollectionAsset, error) {
+ return s.collectionRepo.GetByOwner(ownerUID, starID, limit, offset)
+}
+
+// GetByOwnerAndCategory 获取用户指定分类的典藏藏品
+func (s *collectionService) GetByOwnerAndCategory(ownerUID, starID int64, category string, limit, offset int) ([]*models.CollectionAsset, error) {
+ return s.collectionRepo.GetByOwnerAndCategory(ownerUID, starID, category, limit, offset)
+}
+
+// CountByOwner 统计用户的典藏藏品数量
+func (s *collectionService) CountByOwner(ownerUID, starID int64) (int64, error) {
+ return s.collectionRepo.CountByOwner(ownerUID, starID)
+}
+
+// CountByOwnerAndCategory 统计用户指定分类的典藏藏品数量
+func (s *collectionService) CountByOwnerAndCategory(ownerUID, starID int64, category string) (int64, error) {
+ return s.collectionRepo.CountByOwnerAndCategory(ownerUID, starID, category)
+}
+
+// IncrementLikeCount 增加点赞数
+func (s *collectionService) IncrementLikeCount(id int64) error {
+ return s.collectionRepo.IncrementLikeCount(id)
+}
+
+// DecrementLikeCount 减少点赞数
+func (s *collectionService) DecrementLikeCount(id int64) error {
+ return s.collectionRepo.DecrementLikeCount(id)
+}
diff --git a/backend/services/starbookService/service/starbook_service.go b/backend/services/starbookService/service/starbook_service.go
new file mode 100644
index 0000000..2eb1f82
--- /dev/null
+++ b/backend/services/starbookService/service/starbook_service.go
@@ -0,0 +1,491 @@
+package service
+
+import (
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+
+ "github.com/aliyun/aliyun-oss-go-sdk/oss"
+ "github.com/aliyun/credentials-go/credentials"
+ appErrors "github.com/topfans/backend/pkg/errors"
+ "github.com/topfans/backend/pkg/models"
+ pb "github.com/topfans/backend/pkg/proto/starbook"
+ assetRepo "github.com/topfans/backend/services/assetService/repository"
+ starbookRepo "github.com/topfans/backend/services/starbookService/repository"
+ "gorm.io/gorm"
+)
+
+// StarbookService 星册服务接口
+type StarbookService interface {
+ // GetStarbookHome 获取星册首页数据
+ GetStarbookHome(ownerUID, starID int64) (*pb.GetStarbookHomeResponse, error)
+
+ // GetStarbookItems 获取星册藏品列表(分页)
+ GetStarbookItems(req *pb.GetStarbookItemsRequest, ownerUID, starID int64) (*pb.GetStarbookItemsResponse, error)
+}
+
+// starbookService 星册服务实现
+type starbookService struct {
+ db *gorm.DB
+ registryRepo starbookRepo.AssetRegistryRepository
+ assetRepo assetRepo.AssetRepository
+ collectionRepo starbookRepo.CollectionRepository
+ activityRepo starbookRepo.ActivityAssetRepository
+}
+
+// NewStarbookService 创建星册服务实例
+func NewStarbookService(
+ db *gorm.DB,
+ registryRepo starbookRepo.AssetRegistryRepository,
+ assetRepo assetRepo.AssetRepository,
+ collectionRepo starbookRepo.CollectionRepository,
+ activityRepo starbookRepo.ActivityAssetRepository,
+) StarbookService {
+ return &starbookService{
+ db: db,
+ registryRepo: registryRepo,
+ assetRepo: assetRepo,
+ collectionRepo: collectionRepo,
+ activityRepo: activityRepo,
+ }
+}
+
+// 常量
+const (
+ HomePageSize = 3 // 首页每组最多显示数量
+ CastloveCategory = "castlove"
+ CategoryNameRegular = "原创"
+ CategoryNameCollection = "典藏"
+ CategoryNameActivity = "活动"
+)
+
+// GetStarbookHome 获取星册首页数据
+func (s *starbookService) GetStarbookHome(ownerUID, starID int64) (*pb.GetStarbookHomeResponse, error) {
+ // 1. 查询所有索引记录
+ registries, err := s.registryRepo.GetByOwner(ownerUID, starID)
+ if err != nil {
+ return nil, err
+ }
+
+ // 2. 按 type 分组
+ typeGroups := make(map[string][]*models.AssetRegistry)
+ for _, reg := range registries {
+ typeGroups[reg.AssetType] = append(typeGroups[reg.AssetType], reg)
+ }
+
+ // 3. 构建响应
+ groups := make([]*pb.AssetGroup, 0)
+
+ // 处理原创藏品 (regular)
+ if regs, ok := typeGroups[models.AssetTypeRegular]; ok {
+ group := s.buildRegularGroup(ownerUID, starID, regs)
+ if group != nil {
+ groups = append(groups, group)
+ }
+ }
+
+ // 处理典藏藏品 (collection)
+ if regs, ok := typeGroups[models.AssetTypeCollection]; ok {
+ group := s.buildCollectionGroup(ownerUID, starID, regs)
+ if group != nil {
+ groups = append(groups, group)
+ }
+ }
+
+ // 处理活动藏品 (activity)
+ if regs, ok := typeGroups[models.AssetTypeActivity]; ok {
+ group := s.buildActivityGroup(ownerUID, starID, regs)
+ if group != nil {
+ groups = append(groups, group)
+ }
+ }
+
+ return &pb.GetStarbookHomeResponse{
+ Data: &pb.StarbookHomeData{
+ Groups: groups,
+ },
+ }, nil
+}
+
+// buildRegularGroup 构建原创藏品分组
+func (s *starbookService) buildRegularGroup(ownerUID, starID int64, registries []*models.AssetRegistry) *pb.AssetGroup {
+ // 按 grade 分组
+ gradeGroups := make(map[int32][]*models.AssetRegistry)
+ for _, reg := range registries {
+ if reg.Grade != nil {
+ gradeGroups[*reg.Grade] = append(gradeGroups[*reg.Grade], reg)
+ }
+ }
+
+ // 构建 GradeSection
+ grades := make([]*pb.GradeSection, 0)
+ for grade, regs := range gradeGroups {
+ // 排序:按点赞数降序,保留前3
+ sort.Slice(regs, func(i, j int) bool {
+ return regs[i].LikeCount > regs[j].LikeCount
+ })
+
+ // 截取前3条(点赞数最高的3个)
+ displayRegs := regs
+ hasMore := false
+ if len(regs) > HomePageSize {
+ displayRegs = regs[:HomePageSize]
+ hasMore = true
+ }
+
+ // 获取资产详情并生成预签名URL
+ items := s.buildAssetItemsFromRegistries(displayRegs, models.AssetTypeRegular)
+
+ gradeSection := &pb.GradeSection{
+ Grade: grade,
+ Items: items,
+ TotalCount: int32(len(regs)),
+ HasMore: hasMore,
+ }
+ grades = append(grades, gradeSection)
+ }
+
+ // 按 grade 降序排序
+ sort.Slice(grades, func(i, j int) bool {
+ return grades[i].Grade > grades[j].Grade
+ })
+
+ // 计算 total_count 和 has_more
+ totalCount := int32(0)
+ hasMore := false
+ for _, g := range grades {
+ totalCount += g.TotalCount
+ if g.HasMore {
+ hasMore = true
+ }
+ }
+
+ return &pb.AssetGroup{
+ Type: models.AssetTypeRegular,
+ Category: CastloveCategory,
+ CategoryName: CategoryNameRegular,
+ Grades: grades,
+ TotalCount: totalCount,
+ HasMore: hasMore,
+ }
+}
+
+// buildCollectionGroup 构建典藏藏品分组
+func (s *starbookService) buildCollectionGroup(ownerUID, starID int64, registries []*models.AssetRegistry) *pb.AssetGroup {
+ // 按 category 分组
+ categoryGroups := make(map[string][]*models.AssetRegistry)
+ for _, reg := range registries {
+ if reg.CollectionCategory != nil && *reg.CollectionCategory != "" {
+ categoryGroups[*reg.CollectionCategory] = append(categoryGroups[*reg.CollectionCategory], reg)
+ }
+ }
+
+ // 构建 AssetGroup items
+ allItems := make([]*pb.AssetItem, 0)
+ for category, regs := range categoryGroups {
+ // 排序:按创建时间降序
+ sort.Slice(regs, func(i, j int) bool {
+ return regs[i].CreatedAt > regs[j].CreatedAt
+ })
+
+ // 截取前3条(点赞数最高的3个)
+ displayRegs := regs
+ if len(regs) > HomePageSize {
+ displayRegs = regs[:HomePageSize]
+ }
+
+ // 获取资产详情
+ items := s.buildAssetItemsFromRegistries(displayRegs, models.AssetTypeCollection)
+ for _, item := range items {
+ item.Category = category
+ }
+ allItems = append(allItems, items...)
+ }
+
+ // 计算 total_count 和 has_more
+ totalCount := int32(len(registries))
+ hasMore := len(registries) > HomePageSize
+
+ return &pb.AssetGroup{
+ Type: models.AssetTypeCollection,
+ Category: "",
+ CategoryName: CategoryNameCollection,
+ Items: allItems,
+ TotalCount: totalCount,
+ HasMore: hasMore,
+ }
+}
+
+// buildActivityGroup 构建活动藏品分组
+func (s *starbookService) buildActivityGroup(ownerUID, starID int64, registries []*models.AssetRegistry) *pb.AssetGroup {
+ // 按 activity_type 分组
+ typeGroups := make(map[string][]*models.AssetRegistry)
+ for _, reg := range registries {
+ if reg.ActivityType != nil && *reg.ActivityType != "" {
+ typeGroups[*reg.ActivityType] = append(typeGroups[*reg.ActivityType], reg)
+ }
+ }
+
+ // 构建 AssetGroup items
+ allItems := make([]*pb.AssetItem, 0)
+ for activityType, regs := range typeGroups {
+ // 排序:按创建时间降序
+ sort.Slice(regs, func(i, j int) bool {
+ return regs[i].CreatedAt > regs[j].CreatedAt
+ })
+
+ // 截取前3条(点赞数最高的3个)
+ displayRegs := regs
+ if len(regs) > HomePageSize {
+ displayRegs = regs[:HomePageSize]
+ }
+
+ // 获取资产详情
+ items := s.buildAssetItemsFromRegistries(displayRegs, models.AssetTypeActivity)
+ for _, item := range items {
+ item.Category = activityType
+ }
+ allItems = append(allItems, items...)
+ }
+
+ // 计算 total_count 和 has_more
+ totalCount := int32(len(registries))
+ hasMore := len(registries) > HomePageSize
+
+ return &pb.AssetGroup{
+ Type: models.AssetTypeActivity,
+ Category: "",
+ CategoryName: CategoryNameActivity,
+ Items: allItems,
+ TotalCount: totalCount,
+ HasMore: hasMore,
+ }
+}
+
+// buildAssetItemsFromRegistries 从索引记录构建资产项
+func (s *starbookService) buildAssetItemsFromRegistries(registries []*models.AssetRegistry, assetType string) []*pb.AssetItem {
+ items := make([]*pb.AssetItem, 0)
+ coverURLs := make([]string, 0)
+ assetMap := make(map[string]*pb.AssetItem)
+
+ for _, reg := range registries {
+ item := &pb.AssetItem{
+ AssetId: reg.AssetID,
+ LikeCount: reg.LikeCount,
+ CreatedAt: reg.CreatedAt,
+ Category: CastloveCategory,
+ Grade: 0,
+ }
+
+ // grade 处理
+ if assetType == models.AssetTypeRegular && reg.Grade != nil {
+ item.Grade = *reg.Grade
+ }
+
+ // 获取原始资产信息
+ switch assetType {
+ case models.AssetTypeRegular:
+ if asset, err := s.assetRepo.GetByID(reg.AssetID); err == nil {
+ item.Name = asset.Name
+ coverURLs = append(coverURLs, asset.CoverURL)
+ assetMap[asset.CoverURL] = item
+ }
+ case models.AssetTypeCollection:
+ if colAsset, err := s.collectionRepo.GetByAssetID(reg.AssetID); err == nil {
+ item.Name = colAsset.Name
+ coverURLs = append(coverURLs, colAsset.CoverURL)
+ assetMap[colAsset.CoverURL] = item
+ if colAsset.Category != "" {
+ item.Category = colAsset.Category
+ }
+ }
+ case models.AssetTypeActivity:
+ if actAsset, err := s.activityRepo.GetByAssetID(reg.AssetID); err == nil {
+ item.Name = actAsset.Name
+ coverURLs = append(coverURLs, actAsset.CoverURL)
+ assetMap[actAsset.CoverURL] = item
+ if actAsset.ActivityType != "" {
+ item.Category = actAsset.ActivityType
+ }
+ }
+ }
+
+ items = append(items, item)
+ }
+
+ // 批量生成预签名URL
+ signedURLs := s.batchGeneratePresignedURL(coverURLs)
+ for url, signedURL := range signedURLs {
+ if item, ok := assetMap[url]; ok {
+ item.CoverUrlSigned = signedURL
+ }
+ }
+
+ return items
+}
+
+// GetStarbookItems 获取星册藏品列表(分页)
+func (s *starbookService) GetStarbookItems(req *pb.GetStarbookItemsRequest, ownerUID, starID int64) (*pb.GetStarbookItemsResponse, error) {
+ // 参数验证
+ if req.Type == "" {
+ return nil, appErrors.ErrInvalidAssetType
+ }
+
+ assetType := req.Type
+ page := req.Page
+ if page <= 0 {
+ page = 1
+ }
+ pageSize := req.PageSize
+ if pageSize <= 0 {
+ pageSize = 20
+ }
+ offset := (page - 1) * pageSize
+
+ var registries []*models.AssetRegistry
+ var totalCount int64
+ var err error
+
+ switch assetType {
+ case models.AssetTypeRegular:
+ grade := req.Grade
+ if grade <= 0 {
+ grade = 1
+ }
+ registries, err = s.registryRepo.GetByOwnerAndTypeAndGrade(ownerUID, starID, assetType, grade, int(pageSize), int(offset))
+ if err != nil {
+ return nil, err
+ }
+ totalCount, err = s.registryRepo.CountByOwnerAndTypeAndGrade(ownerUID, starID, assetType, grade)
+ if err != nil {
+ return nil, err
+ }
+
+ case models.AssetTypeCollection:
+ category := req.Category
+ if category == "" {
+ category = CastloveCategory
+ }
+ registries, err = s.registryRepo.GetByOwnerAndTypeAndCategory(ownerUID, starID, assetType, category, int(pageSize), int(offset))
+ if err != nil {
+ return nil, err
+ }
+ totalCount, err = s.registryRepo.CountByOwnerAndTypeAndCategory(ownerUID, starID, assetType, category)
+ if err != nil {
+ return nil, err
+ }
+
+ case models.AssetTypeActivity:
+ category := req.Category
+ if category == "" {
+ category = CastloveCategory
+ }
+ registries, err = s.registryRepo.GetByOwnerAndTypeAndCategory(ownerUID, starID, assetType, category, int(pageSize), int(offset))
+ if err != nil {
+ return nil, err
+ }
+ totalCount, err = s.registryRepo.CountByOwnerAndTypeAndCategory(ownerUID, starID, assetType, category)
+ if err != nil {
+ return nil, err
+ }
+
+ default:
+ return nil, appErrors.ErrInvalidAssetType
+ }
+
+ // 构建 items
+ items := s.buildAssetItemsFromRegistries(registries, assetType)
+
+ hasMore := int64(page*pageSize) < totalCount
+
+ return &pb.GetStarbookItemsResponse{
+ Data: &pb.AssetListData{
+ Items: items,
+ Total: totalCount,
+ Page: page,
+ PageSize: pageSize,
+ HasMore: hasMore,
+ },
+ }, nil
+}
+
+// batchGeneratePresignedURL 批量生成预签名URL
+func (s *starbookService) batchGeneratePresignedURL(urls []string) map[string]string {
+ result := make(map[string]string)
+ for _, url := range urls {
+ signedURL, err := s.generatePresignedURL(url, 3600)
+ if err != nil {
+ result[url] = url // 失败时返回原URL
+ } else {
+ result[url] = signedURL
+ }
+ }
+ return result
+}
+
+// generatePresignedURL 生成预签名URL
+func (s *starbookService) generatePresignedURL(filePath string, expireSeconds int64) (string, error) {
+ region := os.Getenv("OSS_REGION")
+ bucketName := os.Getenv("OSS_BUCKET_NAME")
+ roleArn := os.Getenv("OSS_STS_ROLE_ARN")
+ accessKeyID := os.Getenv("OSS_ACCESS_KEY_ID")
+ accessKeySecret := os.Getenv("OSS_ACCESS_KEY_SECRET")
+
+ if region == "" || bucketName == "" || roleArn == "" || accessKeyID == "" || accessKeySecret == "" {
+ return "", fmt.Errorf("OSS配置不完整")
+ }
+
+ // 使用 STS 方式获取临时凭证
+ credConfig := new(credentials.Config).
+ SetType("ram_role_arn").
+ SetAccessKeyId(accessKeyID).
+ SetAccessKeySecret(accessKeySecret).
+ SetRoleArn(roleArn).
+ SetRoleSessionName("topfans-download-session").
+ SetPolicy("").
+ SetRoleSessionExpiration(int(expireSeconds))
+
+ provider, err := credentials.NewCredential(credConfig)
+ if err != nil {
+ return "", fmt.Errorf("创建凭证提供器失败: %w", err)
+ }
+
+ cred, err := provider.GetCredential()
+ if err != nil {
+ return "", fmt.Errorf("获取临时凭证失败: %w", err)
+ }
+
+ // 创建 OSS 客户端
+ endpoint := fmt.Sprintf("https://oss-%s.aliyuncs.com", region)
+ client, err := oss.New(endpoint, *cred.AccessKeyId, *cred.SecurityToken,
+ oss.SecurityToken(*cred.SecurityToken))
+ if err != nil {
+ return "", fmt.Errorf("创建OSS客户端失败: %w", err)
+ }
+
+ // 获取 Bucket
+ bucket, err := client.Bucket(bucketName)
+ if err != nil {
+ return "", fmt.Errorf("获取Bucket失败: %w", err)
+ }
+
+ // 从完整 URL 中提取 OSS key
+ ossKey := filePath
+ if strings.HasPrefix(filePath, "https://") {
+ parts := strings.SplitN(filePath, ".oss-", 2)
+ if len(parts) == 2 {
+ keyParts := strings.SplitN(parts[1], "/", 2)
+ if len(keyParts) == 2 {
+ ossKey = keyParts[1]
+ }
+ }
+ }
+
+ signedURL, err := bucket.SignURL(ossKey, oss.HTTPGet, expireSeconds)
+ if err != nil {
+ return "", err
+ }
+
+ return signedURL, nil
+}
diff --git a/frontend/pages/components/HorizontalScroll.vue b/frontend/pages/components/HorizontalScroll.vue
new file mode 100644
index 0000000..d60eed8
--- /dev/null
+++ b/frontend/pages/components/HorizontalScroll.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/pages/components/NftCard.vue b/frontend/pages/components/NftCard.vue
index 98f8b37..12e3dca 100644
--- a/frontend/pages/components/NftCard.vue
+++ b/frontend/pages/components/NftCard.vue
@@ -126,7 +126,7 @@ const handleAddClick = () => {