24 KiB
数字藏品功能后续实现计划
📋 文档说明
本文档详细说明了数字藏品功能的后续实现计划,包括具体的实现步骤、代码修改点和测试方案。
创建日期: 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
修改内容:
// 资产相关路由(需要认证)
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
当前定义:
// 资产列表项(简化版,用于列表展示)
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; // 上链成功时间(毫秒时间戳,可选)
}
需要添加:
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
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。
示例:
// 确保查询包含 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。
示例:
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
修改:
// 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
修改:
// 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
添加:
// 上传图片请求
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
添加方法:
// 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])
}
需要添加的导入:
import (
"net/url"
"time"
"github.com/google/uuid"
)
3.3 添加DTO定义
文件: gateway/dto/asset_dto.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
修改:
// 资产相关路由(需要认证)
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
添加:
// 取消铸造订单请求
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
检查状态常量:
// 铸造订单状态常量
const (
MintOrderStatusPending = "PENDING" // 待处理
MintOrderStatusProcessing = "PROCESSING" // 处理中(上链中,目前模拟)
MintOrderStatusSuccess = "SUCCESS" // 成功
MintOrderStatusFailed = "FAILED" // 失败
MintOrderStatusCancelled = "CANCELLED" // 已取消(新增)
)
4.3 实现Repository层
文件: services/assetService/repository/mint_order_repository.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
}
更新接口定义:
// MintOrderRepository 铸造订单Repository接口
type MintOrderRepository interface {
// ... 现有方法
// CancelOrder 取消订单
CancelOrder(orderID string) error
}
4.4 实现Service层
文件: services/assetService/service/mint_service.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
添加方法:
// 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
添加方法:
// 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
添加:
// CancelMintOrderResponseDTO 取消铸造订单响应
type CancelMintOrderResponseDTO struct {
OrderID string `json:"order_id"` // 订单ID
Status string `json:"status"` // 订单状态(CANCELLED)
}
文件: gateway/dto/asset_converter.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
修改:
// 资产相关路由(需要认证)
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
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: 修改路径测试
# 测试新路径
curl -X GET "http://localhost:8080/api/v1/assets/me/items?page=1&page_size=20" \
-H "Authorization: Bearer YOUR_TOKEN"
测试2: 点赞数字段测试
# 验证响应中包含 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: 图片上传测试
# 测试上传接口
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"
}'
预期响应:
{
"code": 200,
"message": "ok",
"data": {
"pic_url": "https://example.com/image.jpg",
"upload_id": "upload_1736234567_abc12345"
}
}
测试4: 取消铸造测试
# 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"
预期响应:
{
"code": 200,
"message": "ok",
"data": {
"order_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "CANCELLED"
}
}
测试5: 取消铸造边界测试
# 测试取消已成功的订单(应该失败)
curl -X DELETE "http://localhost:8080/api/v1/assets/mints/SUCCESS_ORDER_ID" \
-H "Authorization: Bearer YOUR_TOKEN"
# 预期: 返回错误,提示"已上链成功的订单不能取消"
⚠️ 注意事项
-
路由顺序:
/upload必须在/:asset_id之前/mints/:order_id必须在/:asset_id之前/me/items必须在/:asset_id之前
-
Proto编译:
- 修改Proto后必须重新编译
- 编译命令:
./scripts/compile-proto.sh
-
状态管理:
- 取消订单时不需要退回水晶
- 只能取消
PENDING或PROCESSING状态的订单
-
图片上传:
- 目前只是接收URL并返回,不进行实际处理
- 后续可以扩展审核和AI生成功能
-
点赞数:
- 确保数据库查询包含
like_count字段 - 确保所有层都正确传递和转换
- 确保数据库查询包含
📝 后续扩展(可选)
图片上传功能扩展
- 实际文件上传(multipart/form-data)
- 图片存储到OSS/S3
- 图片审核功能
- AI生成封面功能
取消铸造功能扩展
- 取消时删除关联资产(可选)
- 取消原因记录
- 取消通知功能
文档版本: v1.0
最后更新: 2026-01-12
维护者: AI Assistant