# 数字藏品功能后续实现计划 ## 📋 文档说明 本文档详细说明了数字藏品功能的后续实现计划,包括具体的实现步骤、代码修改点和测试方案。 **创建日期**: 2026-01-12 **维护者**: AI Assistant **状态**: 🟡 待实现 --- ## 📊 实现任务总览 | 任务 | 优先级 | 预计工作量 | 状态 | |------|--------|-----------|------| | 1. 修改获取我的藏品列表路径 | 🔴 P0 | 0.5小时 | ⏳ 待实现 | | 2. 添加点赞数字段到列表响应 | 🔴 P0 | 1小时 | ⏳ 待实现 | | 3. 实现图片上传接口(URL方式) | 🔴 P0 | 1-2小时 | ⏳ 待实现 | | 4. 实现取消铸造功能 | 🔴 P0 | 2-3小时 | ⏳ 待实现 | **总预计工作量**: 4.5-6.5小时 --- ## 🎯 任务1: 修改获取我的藏品列表路径 ### 需求说明 - **当前路径**: `GET /api/v1/assets/me` - **目标路径**: `GET /api/v1/assets/me/items` - **说明**: 仅修改路径,功能保持不变 ### 实现步骤 #### 1.1 修改路由配置 **文件**: `gateway/router/router.go` **修改内容**: ```go // 资产相关路由(需要认证) assets := v1.Group("/assets") assets.Use(middleware.AuthMiddleware()) { assets.POST("/mints", assetCtrl.CreateMintOrder) // 创建铸造订单 assets.GET("/me/items", assetCtrl.GetMyAssets) // 获取我的藏品列表(修改路径) assets.GET("/:asset_id", assetCtrl.GetAsset) // 获取资产详情 assets.GET("/:asset_id/status", assetCtrl.GetAssetStatus) // 查询上链状态 } ``` **注意**: 路径 `/me/items` 必须在 `/:asset_id` 之前定义,避免路由冲突。 --- ## 🎯 任务2: 添加点赞数字段到列表响应 ### 需求说明 - 在 `GetMyAssets` 接口的响应中,每个列表项需要包含 `like_count` 字段 - 点赞数已经在 `Asset` 模型中存在,需要确保在列表响应中返回 ### 实现步骤 #### 2.1 检查Proto定义 **文件**: `proto/asset.proto` **当前定义**: ```protobuf // 资产列表项(简化版,用于列表展示) message AssetListItem { int64 asset_id = 1; // 资产ID string name = 2; // 藏品名称 string cover_url = 3; // 封面图URL string status = 4; // 状态:pending, minting, minted, failed string tx_hash = 5; // 交易哈希(可选) int64 created_at = 6; // 创建时间(毫秒时间戳) int64 minted_at = 7; // 上链成功时间(毫秒时间戳,可选) } ``` **需要添加**: ```protobuf message AssetListItem { int64 asset_id = 1; // 资产ID string name = 2; // 藏品名称 string cover_url = 3; // 封面图URL string status = 4; // 状态:pending, minting, minted, failed string tx_hash = 5; // 交易哈希(可选) int64 created_at = 6; // 创建时间(毫秒时间戳) int64 minted_at = 7; // 上链成功时间(毫秒时间戳,可选) int32 like_count = 8; // 点赞数(新增) } ``` #### 2.2 重新编译Proto ```bash cd /Users/haihuizhu/infinite_matrix/TopFans/backend ./scripts/compile-proto.sh ``` #### 2.3 修改Repository层 **文件**: `services/assetService/repository/asset_repository.go` 检查 `GetMyAssets` 方法是否已经查询了 `like_count` 字段。如果使用 `Select` 查询,需要确保包含 `like_count`。 **示例**: ```go // 确保查询包含 like_count 字段 var assets []models.Asset err := r.db. Select("id", "name", "cover_url", "status", "tx_hash", "created_at", "minted_at", "like_count"). Where("owner_uid = ? AND star_id = ?", userID, starID). // ... 其他查询条件 Find(&assets).Error ``` #### 2.4 修改Service层 **文件**: `services/assetService/service/asset_service.go` 检查 `GetMyAssets` 方法,确保在构建 `AssetListItem` 时包含 `like_count`。 #### 2.5 修改Provider层 **文件**: `services/assetService/provider/asset_provider.go` 检查 `GetMyAssets` 方法,确保在转换响应时包含 `like_count`。 **示例**: ```go items := make([]*pbAsset.AssetListItem, 0, len(assets)) for _, asset := range assets { items = append(items, &pbAsset.AssetListItem{ AssetId: asset.ID, Name: asset.Name, CoverUrl: asset.CoverURL, Status: convertAssetStatus(asset.Status), TxHash: getStringValue(asset.TxHash), CreatedAt: asset.CreatedAt, MintedAt: getInt64Value(asset.MintedAt), LikeCount: asset.LikeCount, // 新增 }) } ``` #### 2.6 修改DTO转换 **文件**: `gateway/dto/asset_dto.go` **修改**: ```go // AssetListItemDTO 资产列表项(简化版,用于列表展示) type AssetListItemDTO struct { AssetID int64 `json:"asset_id"` // 资产ID Name string `json:"name"` // 藏品名称 CoverURL string `json:"cover_url"` // 封面图URL Status string `json:"status"` // 状态:pending, minting, minted, failed TxHash string `json:"tx_hash,omitempty"` // 交易哈希(可选) CreatedAt int64 `json:"created_at"` // 创建时间(毫秒时间戳) MintedAt int64 `json:"minted_at,omitempty"` // 上链成功时间(毫秒时间戳,可选) LikeCount int32 `json:"like_count"` // 点赞数(新增) } ``` **文件**: `gateway/dto/asset_converter.go` **修改**: ```go // ConvertAssetListItem 转换资产列表项 func ConvertAssetListItem(pbItem *pbAsset.AssetListItem) AssetListItemDTO { dto := AssetListItemDTO{ AssetID: pbItem.AssetId, Name: pbItem.Name, CoverURL: pbItem.CoverUrl, Status: pbItem.Status, CreatedAt: pbItem.CreatedAt, LikeCount: pbItem.LikeCount, // 新增 } // 可选字段 if pbItem.TxHash != "" { dto.TxHash = pbItem.TxHash } if pbItem.MintedAt > 0 { dto.MintedAt = pbItem.MintedAt } return dto } ``` --- ## 🎯 任务3: 实现图片上传接口(URL方式) ### 需求说明 - **接口路径**: `POST /api/v1/assets/upload` - **功能**: 接收图片URL,返回处理后的URL(目前暂定为直接返回,后续可扩展审核和AI生成) - **说明**: 目前不需要实际上传文件,只需要接收URL并返回 ### 实现步骤 #### 3.1 添加Proto定义(可选) **文件**: `proto/asset.proto` **添加**: ```protobuf // 上传图片请求 message UploadImageRequest { string image_url = 1; // 图片URL(必填) string type = 2; // 上传类型:cover, material(可选,默认cover) } // 上传图片响应 message UploadImageResponse { topfans.common.BaseResponse base = 1; string pic_url = 2; // 处理后的图片URL string upload_id = 3; // 上传ID(可选,用于后续取消) } // 在 AssetService 中添加 service AssetService { // ... 现有方法 // 上传图片 rpc UploadImage(UploadImageRequest) returns (UploadImageResponse) { option (google.api.http) = { post: "/api/v1/assets/upload" body: "*" }; } } ``` **注意**: 如果不想修改Proto,可以直接在Gateway层实现,不调用AssetService。 #### 3.2 实现Gateway层接口(推荐方案) **文件**: `gateway/controller/asset_controller.go` **添加方法**: ```go // UploadImage 上传图片(URL方式) // POST /api/v1/assets/upload func (ctrl *AssetController) UploadImage(c *gin.Context) { // 解析请求参数 var req struct { ImageURL string `json:"image_url" binding:"required"` Type string `json:"type"` // cover, material,默认cover } if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, http.StatusBadRequest, "参数错误: "+err.Error()) return } // 验证URL格式 if !isValidURL(req.ImageURL) { response.Error(c, http.StatusBadRequest, "无效的图片URL") return } // 设置默认类型 if req.Type == "" { req.Type = "cover" } // 验证类型 if req.Type != "cover" && req.Type != "material" { response.Error(c, http.StatusBadRequest, "type必须是cover或material") return } // TODO: 后续可以添加图片审核逻辑 // TODO: 后续可以添加AI生成逻辑 // 目前直接返回原URL // 后续可以: // 1. 下载图片到本地/OSS // 2. 进行审核 // 3. AI处理 // 4. 返回处理后的URL // 生成上传ID(可选) uploadID := generateUploadID() response.Success(c, gin.H{ "pic_url": req.ImageURL, // 目前直接返回原URL "upload_id": uploadID, // 上传ID,用于后续取消 }) } // isValidURL 验证URL格式 func isValidURL(url string) bool { u, err := url.Parse(url) return err == nil && u.Scheme != "" && u.Host != "" } // generateUploadID 生成上传ID func generateUploadID() string { return fmt.Sprintf("upload_%d_%s", time.Now().Unix(), uuid.New().String()[:8]) } ``` **需要添加的导入**: ```go import ( "net/url" "time" "github.com/google/uuid" ) ``` #### 3.3 添加DTO定义 **文件**: `gateway/dto/asset_dto.go` **添加**: ```go // UploadImageRequestDTO 上传图片请求 type UploadImageRequestDTO struct { ImageURL string `json:"image_url" binding:"required"` // 图片URL(必填) Type string `json:"type"` // 上传类型:cover, material(可选,默认cover) } // UploadImageResponseDTO 上传图片响应 type UploadImageResponseDTO struct { PicURL string `json:"pic_url"` // 处理后的图片URL UploadID string `json:"upload_id"` // 上传ID(可选,用于后续取消) } ``` #### 3.4 添加路由 **文件**: `gateway/router/router.go` **修改**: ```go // 资产相关路由(需要认证) assets := v1.Group("/assets") assets.Use(middleware.AuthMiddleware()) { assets.POST("/upload", assetCtrl.UploadImage) // 上传图片(新增) assets.POST("/mints", assetCtrl.CreateMintOrder) // 创建铸造订单 assets.GET("/me/items", assetCtrl.GetMyAssets) // 获取我的藏品列表 assets.GET("/:asset_id", assetCtrl.GetAsset) // 获取资产详情 assets.GET("/:asset_id/status", assetCtrl.GetAssetStatus) // 查询上链状态 } ``` **注意**: `/upload` 路由必须在 `/:asset_id` 之前定义,避免路由冲突。 --- ## 🎯 任务4: 实现取消铸造功能 ### 需求说明 - **接口路径**: `DELETE /api/v1/assets/mints/:order_id` - **功能**: 取消指定的铸造订单 - **业务规则**: - 只能取消状态为 `PENDING` 或 `PROCESSING` 的订单 - 不能取消已成功(`SUCCESS`)或已失败(`FAILED`)的订单 - **不需要退回水晶**(因为只有在创建mint订单时才扣除) - 取消后订单状态变为 `CANCELLED` ### 实现步骤 #### 4.1 添加Proto定义 **文件**: `proto/asset.proto` **添加**: ```protobuf // 取消铸造订单请求 message CancelMintOrderRequest { string order_id = 1; // 订单ID(必填) } // 取消铸造订单响应 message CancelMintOrderResponse { topfans.common.BaseResponse base = 1; string order_id = 2; // 订单ID string status = 3; // 订单状态(CANCELLED) } // 在 AssetService 中添加 service AssetService { // ... 现有方法 // 取消铸造订单 rpc CancelMintOrder(CancelMintOrderRequest) returns (CancelMintOrderResponse) { option (google.api.http) = { delete: "/api/v1/assets/mints/{order_id}" }; } } ``` #### 4.2 更新数据库模型(如果需要) **文件**: `pkg/models/asset.go` **检查状态常量**: ```go // 铸造订单状态常量 const ( MintOrderStatusPending = "PENDING" // 待处理 MintOrderStatusProcessing = "PROCESSING" // 处理中(上链中,目前模拟) MintOrderStatusSuccess = "SUCCESS" // 成功 MintOrderStatusFailed = "FAILED" // 失败 MintOrderStatusCancelled = "CANCELLED" // 已取消(新增) ) ``` #### 4.3 实现Repository层 **文件**: `services/assetService/repository/mint_order_repository.go` **添加方法**: ```go // CancelOrder 取消订单 func (r *mintOrderRepository) CancelOrder(orderID string) error { if orderID == "" { return errors.New("order_id cannot be empty") } // 更新订单状态为CANCELLED result := r.db.Model(&models.MintOrder{}). Where("order_id = ?", orderID). Updates(map[string]interface{}{ "status": models.MintOrderStatusCancelled, "updated_at": time.Now().UnixMilli(), }) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return appErrors.NewNotFoundError("订单不存在") } return nil } ``` **更新接口定义**: ```go // MintOrderRepository 铸造订单Repository接口 type MintOrderRepository interface { // ... 现有方法 // CancelOrder 取消订单 CancelOrder(orderID string) error } ``` #### 4.4 实现Service层 **文件**: `services/assetService/service/mint_service.go` **添加方法**: ```go // CancelMintOrder 取消铸造订单 func (s *mintService) CancelMintOrder(orderID string, userID, starID int64) error { // 1. 查询订单 order, err := s.mintOrderRepo.GetByOrderIDAndUser(orderID, userID, starID) if err != nil { return err } // 2. 验证订单状态 if order.Status == models.MintOrderStatusSuccess { return appErrors.NewBadRequestError("已上链成功的订单不能取消") } if order.Status == models.MintOrderStatusFailed { return appErrors.NewBadRequestError("已失败的订单不能取消") } if order.Status == models.MintOrderStatusCancelled { return appErrors.NewBadRequestError("订单已取消") } // 3. 只能取消 PENDING 或 PROCESSING 状态的订单 if order.Status != models.MintOrderStatusPending && order.Status != models.MintOrderStatusProcessing { return appErrors.NewBadRequestError("订单状态不允许取消") } // 4. 取消订单(不需要退回水晶,因为创建时已扣除) if err := s.mintOrderRepo.CancelOrder(orderID); err != nil { return err } // 5. 如果订单已关联资产,可以选择删除资产(可选) // 根据业务需求决定是否删除资产 // if order.AssetID != nil { // // 删除资产逻辑 // } return nil } ``` #### 4.5 实现Provider层 **文件**: `services/assetService/provider/asset_provider.go` **添加方法**: ```go // CancelMintOrder 取消铸造订单 func (p *AssetProvider) CancelMintOrder(ctx context.Context, req *pb.CancelMintOrderRequest) (*pb.CancelMintOrderResponse, error) { logger.Logger.Info("Received CancelMintOrder request", zap.String("order_id", req.OrderId), ) // 从 Dubbo attachments 获取用户信息 userID, starID, err := extractUserInfoFromDubboAttachments(ctx) if err != nil { logger.Logger.Error("Failed to extract user info from attachments", zap.Error(err), ) return &pb.CancelMintOrderResponse{ Base: &pbCommon.BaseResponse{ Code: pbCommon.StatusCode_STATUS_UNAUTHORIZED, Message: "user authentication required", Timestamp: 0, }, }, err } // 调用Service层 err = p.mintService.CancelMintOrder(req.OrderId, userID, starID) if err != nil { logger.Logger.Error("CancelMintOrder failed", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.String("order_id", req.OrderId), zap.Error(err), ) return &pb.CancelMintOrderResponse{ Base: &pbCommon.BaseResponse{ Code: appErrors.ToStatusCode(err), Message: err.Error(), Timestamp: 0, }, }, err } logger.Logger.Info("CancelMintOrder successful", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.String("order_id", req.OrderId), ) return &pb.CancelMintOrderResponse{ Base: &pbCommon.BaseResponse{ Code: pbCommon.StatusCode_STATUS_OK, Message: "订单已取消", Timestamp: time.Now().UnixMilli(), }, OrderId: req.OrderId, Status: "CANCELLED", }, nil } ``` #### 4.6 实现Gateway层 **文件**: `gateway/controller/asset_controller.go` **添加方法**: ```go // CancelMintOrder 取消铸造订单 // DELETE /api/v1/assets/mints/:order_id func (ctrl *AssetController) CancelMintOrder(c *gin.Context) { userID, _ := c.Get("user_id") starID, _ := c.Get("star_id") // 解析路径参数 orderID := c.Param("order_id") if orderID == "" { response.Error(c, http.StatusBadRequest, "参数错误: order_id 不能为空") return } // 设置上下文 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{ "user_id": strconv.FormatInt(userID.(int64), 10), "star_id": strconv.FormatInt(starID.(int64), 10), }) // 调用 RPC resp, err := ctrl.assetService.CancelMintOrder(ctx, &pbAsset.CancelMintOrderRequest{ OrderId: orderID, }) if err != nil { logger.Logger.Error("CancelMintOrder RPC failed", zap.String("order_id", orderID), zap.Error(err), ) response.Error(c, http.StatusInternalServerError, "服务调用失败") return } if resp.Base.Code != pbCommon.StatusCode_STATUS_OK { response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message) return } // 转换响应 data := dto.ConvertCancelMintOrderResponse(resp) response.Success(c, data) } ``` #### 4.7 添加DTO定义 **文件**: `gateway/dto/asset_dto.go` **添加**: ```go // CancelMintOrderResponseDTO 取消铸造订单响应 type CancelMintOrderResponseDTO struct { OrderID string `json:"order_id"` // 订单ID Status string `json:"status"` // 订单状态(CANCELLED) } ``` **文件**: `gateway/dto/asset_converter.go` **添加**: ```go // ConvertCancelMintOrderResponse 转换取消铸造订单响应 func ConvertCancelMintOrderResponse(pbResp *pbAsset.CancelMintOrderResponse) *CancelMintOrderResponseDTO { if pbResp == nil { return nil } return &CancelMintOrderResponseDTO{ OrderID: pbResp.OrderId, Status: pbResp.Status, } } ``` #### 4.8 添加路由 **文件**: `gateway/router/router.go` **修改**: ```go // 资产相关路由(需要认证) assets := v1.Group("/assets") assets.Use(middleware.AuthMiddleware()) { assets.POST("/upload", assetCtrl.UploadImage) // 上传图片 assets.POST("/mints", assetCtrl.CreateMintOrder) // 创建铸造订单 assets.DELETE("/mints/:order_id", assetCtrl.CancelMintOrder) // 取消铸造订单(新增) assets.GET("/me/items", assetCtrl.GetMyAssets) // 获取我的藏品列表 assets.GET("/:asset_id", assetCtrl.GetAsset) // 获取资产详情 assets.GET("/:asset_id/status", assetCtrl.GetAssetStatus) // 查询上链状态 } ``` **注意**: `/mints/:order_id` 路由必须在 `/:asset_id` 之前定义,避免路由冲突。 #### 4.9 重新编译Proto ```bash cd /Users/haihuizhu/infinite_matrix/TopFans/backend ./scripts/compile-proto.sh ``` --- ## 📋 实现检查清单 ### 任务1: 修改路径 - [ ] 修改 `gateway/router/router.go` 中的路由路径 - [ ] 测试新路径是否正常工作 ### 任务2: 添加点赞数 - [ ] 修改 `proto/asset.proto` 添加 `like_count` 字段 - [ ] 重新编译Proto - [ ] 检查Repository层是否查询了 `like_count` - [ ] 检查Service层是否返回了 `like_count` - [ ] 检查Provider层是否转换了 `like_count` - [ ] 修改 `gateway/dto/asset_dto.go` 添加字段 - [ ] 修改 `gateway/dto/asset_converter.go` 添加转换 - [ ] 测试接口返回是否包含 `like_count` ### 任务3: 图片上传接口 - [ ] 在 `gateway/controller/asset_controller.go` 添加 `UploadImage` 方法 - [ ] 在 `gateway/dto/asset_dto.go` 添加DTO定义 - [ ] 在 `gateway/router/router.go` 添加路由 - [ ] 测试上传接口 ### 任务4: 取消铸造功能 - [ ] 修改 `proto/asset.proto` 添加RPC定义 - [ ] 更新 `pkg/models/asset.go` 添加 `CANCELLED` 状态 - [ ] 在 `repository/mint_order_repository.go` 添加 `CancelOrder` 方法 - [ ] 在 `service/mint_service.go` 添加 `CancelMintOrder` 方法 - [ ] 在 `provider/asset_provider.go` 添加 `CancelMintOrder` 方法 - [ ] 在 `gateway/controller/asset_controller.go` 添加 `CancelMintOrder` 方法 - [ ] 在 `gateway/dto/asset_dto.go` 添加DTO定义 - [ ] 在 `gateway/dto/asset_converter.go` 添加转换 - [ ] 在 `gateway/router/router.go` 添加路由 - [ ] 重新编译Proto - [ ] 测试取消功能 --- ## 🧪 测试方案 ### 测试1: 修改路径测试 ```bash # 测试新路径 curl -X GET "http://localhost:8080/api/v1/assets/me/items?page=1&page_size=20" \ -H "Authorization: Bearer YOUR_TOKEN" ``` ### 测试2: 点赞数字段测试 ```bash # 验证响应中包含 like_count curl -X GET "http://localhost:8080/api/v1/assets/me/items" \ -H "Authorization: Bearer YOUR_TOKEN" | jq '.data.items[0].like_count' ``` ### 测试3: 图片上传测试 ```bash # 测试上传接口 curl -X POST http://localhost:8080/api/v1/assets/upload \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "image_url": "https://example.com/image.jpg", "type": "cover" }' ``` **预期响应**: ```json { "code": 200, "message": "ok", "data": { "pic_url": "https://example.com/image.jpg", "upload_id": "upload_1736234567_abc12345" } } ``` ### 测试4: 取消铸造测试 ```bash # 1. 先创建一个订单 ORDER_ID=$(curl -X POST http://localhost:8080/api/v1/assets/mints \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "测试藏品", "cover_url": "https://example.com/cover.jpg" }' | jq -r '.data.order.order_id') # 2. 取消订单 curl -X DELETE "http://localhost:8080/api/v1/assets/mints/${ORDER_ID}" \ -H "Authorization: Bearer YOUR_TOKEN" ``` **预期响应**: ```json { "code": 200, "message": "ok", "data": { "order_id": "550e8400-e29b-41d4-a716-446655440000", "status": "CANCELLED" } } ``` ### 测试5: 取消铸造边界测试 ```bash # 测试取消已成功的订单(应该失败) curl -X DELETE "http://localhost:8080/api/v1/assets/mints/SUCCESS_ORDER_ID" \ -H "Authorization: Bearer YOUR_TOKEN" # 预期: 返回错误,提示"已上链成功的订单不能取消" ``` --- ## ⚠️ 注意事项 1. **路由顺序**: - `/upload` 必须在 `/:asset_id` 之前 - `/mints/:order_id` 必须在 `/:asset_id` 之前 - `/me/items` 必须在 `/:asset_id` 之前 2. **Proto编译**: - 修改Proto后必须重新编译 - 编译命令: `./scripts/compile-proto.sh` 3. **状态管理**: - 取消订单时不需要退回水晶 - 只能取消 `PENDING` 或 `PROCESSING` 状态的订单 4. **图片上传**: - 目前只是接收URL并返回,不进行实际处理 - 后续可以扩展审核和AI生成功能 5. **点赞数**: - 确保数据库查询包含 `like_count` 字段 - 确保所有层都正确传递和转换 --- ## 📝 后续扩展(可选) ### 图片上传功能扩展 - [ ] 实际文件上传(multipart/form-data) - [ ] 图片存储到OSS/S3 - [ ] 图片审核功能 - [ ] AI生成封面功能 ### 取消铸造功能扩展 - [ ] 取消时删除关联资产(可选) - [ ] 取消原因记录 - [ ] 取消通知功能 --- **文档版本**: v1.0 **最后更新**: 2026-01-12 **维护者**: AI Assistant