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 = () => {