topfans/backend/docs/展馆服务开发流程.md
2026-04-07 22:29:48 +08:00

19 KiB
Raw Blame History

展馆服务开发流程文档

📋 文档说明

本文档详细说明了展馆服务Gallery Service的开发流程包括各阶段的实现步骤、代码示例、测试要求等。

创建日期: 2026-01-12
维护者: AI Assistant
状态: 🟡 开发指南

参考文档

  • docs/展馆服务设计方案.md - 详细设计方案
  • docs/HTTP REST API接口文档.md - API接口定义
  • services/assetService/IMPLEMENTATION_COMPLETE.md - Asset Service实现参考

一、开发环境准备

1.1 前置条件

  • Go 1.21+
  • PostgreSQL 数据库
  • Dubbo-go 框架
  • 已实现 User Service 和 Asset Service
  • 已配置 Gateway 层

1.2 目录结构

services/galleryService/
├── main.go                    # 主程序入口
├── go.mod                     # 依赖管理
├── go.sum                     # 依赖锁定
├── configs/
│   └── dubbo.yaml            # Dubbo配置
├── config/
│   └── gallery_config.go     # 展馆服务配置(硬编码规则)
├── repository/
│   ├── gallery_repository.go         # 展馆数据访问层
│   ├── gallery_repository_test.go    # Repository测试
│   └── README.md                     # Repository文档
├── service/
│   ├── gallery_service.go    # 展馆业务逻辑
│   ├── slot_service.go       # 展位业务逻辑
│   └── exhibition_service.go # 展品业务逻辑
├── client/
│   ├── asset_rpc_client.go   # Asset Service RPC客户端
│   ├── user_rpc_client.go    # User Service RPC客户端
│   └── task_rpc_client.go    # Task Service RPC客户端可选
├── provider/
│   └── gallery_provider.go   # RPC接口实现
└── IMPLEMENTATION_COMPLETE.md # 实现完成总结

二、阶段一:项目初始化与基础配置

2.1 创建项目结构

services/ 目录下创建 galleryService 文件夹,并初始化 go.mod

mkdir -p services/galleryService/{config,configs,repository,service,client,provider}
cd services/galleryService
go mod init github.com/topfans/backend/services/galleryService

2.2 定义基础配置 (Config)

文件services/galleryService/config/gallery_config.go

职责定义服务启动所需的硬编码规则、DB 连接参数和 RPC 依赖地址。

代码示例

package config

var (
    // 业务规则(硬编码)
    GalleryRules = &GalleryRulesConfig{
        InitialSlotCount: 3,
        GrabSlotDuration: 14400, // 4小时
        UnlockLevelBySlot: map[int]int{4: 5, 5: 6},
        UnlockCrystalBySlot: map[int]int{4: 100, 5: 200},
    }

    // 基础设施配置(由 main.go 的 flag 或环境变量注入)
    DBConfig = &DatabaseConfig{}
    ServiceURLs = struct {
        AssetService string
        UserService  string
    }{
        AssetService: "tri://localhost:20003",
        UserService:  "tri://localhost:20000",
    }
)

2.3 Proto 编译

文件proto/gallery.proto

  1. 按照设计方案编写 Proto。
  2. 运行 ./scripts/compile-proto.sh 生成代码。

2.4 数据库模型与迁移

文件pkg/models/gallery.go

  1. 定义 BoothSlotExhibition 模型。
  2. 在数据库中执行 SQL 脚本创建表和 UNIQUE 索引。

三、阶段二Repository 层实现

3.1 创建Repository接口

文件services/galleryService/repository/gallery_repository.go

主要方法

type GalleryRepository interface {
    // 展馆相关
    GetGalleryByUser(userID, starID int64) (*Gallery, error)
    CreateInitialSlots(userID, starID int64) error  // 懒加载创建初始展位
    
    // 展位相关
    GetSlotsByGallery(hostProfileID int64) ([]*BoothSlot, error)
    GetSlotByID(slotID int64) (*BoothSlot, error)
    CreateSlot(slot *BoothSlot) error
    UpdateSlotStatus(slotID int64, status string) error
    UnlockSlot(slotID int64) error
    
    // 展品相关
    GetExhibitionByAsset(assetID int64) (*Exhibition, error)
    GetExhibitionBySlot(slotID int64) (*Exhibition, error)
    CreateExhibition(exhibition *Exhibition) error
    DeleteExhibition(exhibitionID int64) error
    GetExpiredExhibitions(beforeTime int64) ([]*Exhibition, error)
}

3.2 实现懒加载逻辑

关键实现CreateInitialSlots

步骤

  1. 检查是否已存在展位(避免重复创建)
  2. 使用事务创建初始展位3个
  3. 设置初始展位为已解锁is_enabled = true
  4. 设置初始展位为公有visibility = 'public'

示例

func (r *galleryRepository) CreateInitialSlots(userID, starID int64) error {
    // 使用 PostgreSQL 的 ON CONFLICT 保证并发安全性
    return r.db.Transaction(func(tx *gorm.DB) error {
        now := time.Now().UnixMilli()
        initialSlotCount := config.GalleryRules.InitialSlotCount
        
        for i := 1; i <= initialSlotCount; i++ {
            slot := &models.BoothSlot{
                HostProfileID: getHostProfileID(userID, starID),
                UserID:        userID,
                StarID:        starID,
                SlotIndex:     i,
                Visibility:    "public",
                IsEnabled:     true,
                UnlockType:    "free",
                UnlockValue:   0,
                CreatedAt:     now,
                UpdatedAt:     now,
            }
            
            // 使用 Clause 处理冲突,确保幂等
            err := tx.Clauses(clause.OnConflict{
                Columns:   []clause.Column{{Name: "host_profile_id"}, {Name: "slot_index"}},
                DoNothing: true,
            }).Create(slot).Error
            
            if err != nil {
                return err
            }
        }
        return nil
    })
}

3.3 编写单元测试

文件services/galleryService/repository/gallery_repository_test.go

测试覆盖

  • 创建初始展位
  • 查询展位列表
  • 创建展品展示记录
  • 查询展品展示记录
  • 删除展品展示记录
  • 懒加载逻辑(避免重复创建)

验收标准

  • 所有Repository方法实现完成
  • 单元测试通过
  • 测试覆盖率 > 80%
  • 懒加载逻辑正确

四、阶段三Service 层实现

4.1 完善业务逻辑配置

config/gallery_config.go 中补充详细的业务映射(如解锁等级的具体数值),这些配置将在 Service 层被引用。

4.2 实现展馆服务

文件services/galleryService/service/gallery_service.go

关键方法

  • GetMyGallery:获取我的展馆(包含懒加载逻辑)
  • GetUserGallery:获取他人展馆

懒加载实现

func (s *galleryService) GetMyGallery(userID, starID int64) (*GalleryResponse, error) {
    // 1. 查询展位列表
    slots, err := s.repo.GetSlotsByGallery(hostProfileID)
    if err != nil {
        return nil, err
    }
    
    // 2. 如果不存在展位,懒加载创建初始展位
    if len(slots) == 0 {
        if err := s.repo.CreateInitialSlots(userID, starID); err != nil {
            return nil, err
        }
        // 重新查询
        slots, err = s.repo.GetSlotsByGallery(hostProfileID)
        if err != nil {
            return nil, err
        }
    }
    
    // 3. 填充展品信息
    // ...
}

4.3 实现展位服务

文件services/galleryService/service/slot_service.go

关键方法

  • UnlockSlot:解锁/购买新展位

解锁逻辑(优先等级解锁):

func (s *slotService) UnlockSlot(userID, starID int64) (*UnlockSlotResponse, error) {
    // 1. 获取粉丝档案(等级、水晶余额)
    profile, err := s.userClient.GetFanProfile(userID, starID)
    if err != nil {
        return nil, err
    }
    
    // 2. 查询当前展位数
    currentSlotCount := s.repo.GetSlotCount(userID, starID)
    nextSlotIndex := currentSlotCount + 1
    
    // 3. 获取解锁规则
    requiredLevel := config.GalleryRules.UnlockLevelBySlot[nextSlotIndex]
    requiredCrystal := config.GalleryRules.UnlockCrystalBySlot[nextSlotIndex]
    
    // 4. 优先检查等级解锁
    if profile.Level >= requiredLevel {
        // 等级足够,直接解锁(不消耗水晶)
        slot := &models.BoothSlot{
            // ... 设置字段
            IsEnabled: true,
            UnlockType: "level",
        }
        if err := s.repo.CreateSlot(slot); err != nil {
            return nil, err
        }
        return &UnlockSlotResponse{...}, nil
    }
    
    // 5. 等级不够,检查水晶购买
    if profile.CrystalBalance >= requiredCrystal {
        // 扣除水晶
        if err := s.userClient.UpdateCrystalBalance(userID, starID, -requiredCrystal); err != nil {
            return nil, err
        }
        
        // 创建展位
        slot := &models.BoothSlot{
            // ... 设置字段
            IsEnabled: true,
            UnlockType: "crystal",
        }
        if err := s.repo.CreateSlot(slot); err != nil {
            return nil, err
        }
        return &UnlockSlotResponse{...}, nil
    }
    
    // 6. 都不满足,返回错误
    return nil, errors.New("等级和水晶余额都不足")
}

4.4 实现展品服务

文件services/galleryService/service/exhibition_service.go

关键方法

  • PlaceAsset:在展位展示藏品(支持放置到他人展馆)
  • RemoveAsset:下架展位藏品
  • KickOccupier:踢走占位

放置逻辑包含权限校验与方案A实现

func (s *exhibitionService) PlaceAsset(userID, starID int64, req *PlaceAssetRequest) error {
    // 1. 验证资产是否存在且属于当前用户 (RPC Asset Service)
    asset, err := s.assetClient.GetAssetForRPC(req.AssetID, userID, starID)
    if err != nil {
        return err
    }
    
    // 2. 获取目标展位信息
    slot, err := s.repo.GetSlotByID(req.SlotID)
    if err != nil {
        return err
    }
    if !slot.IsEnabled {
        return errors.New("展位未解锁")
    }
    
    // 3. 校验权限与可见性 (新细节)
    isOwner := slot.UserID == userID
    if !isOwner {
        // 如果不是自己的展位,必须是 public 且在同一个明星下
        if slot.Visibility != "public" {
            return errors.New("该展位是私有的,无法放置展品")
        }
        if slot.StarID != starID {
            return errors.New("只能在同一明星的展馆中放置展品")
        }
    }
    
    // 4. 执行放置逻辑
    now := time.Now().UnixMilli()
    expireAt := now + config.GalleryRules.GrabSlotDuration*1000
    
    exhibition := &models.Exhibition{
        AssetID:        req.AssetID,
        SlotID:         req.SlotID,
        HostProfileID:  slot.HostProfileID,
        OccupierUID:    userID,
        OccupierStarID: starID,
        StartTime:      now,
        ExpireAt:       expireAt,
        CreatedAt:      now,
        UpdatedAt:      now,
    }
    
    // 5. 使用数据库唯一索引保证原子性 (方案A)
    // 如果该 asset_id 已在其他地方展示,此处会报 DB Error
    if err := s.repo.CreateExhibition(exhibition); err != nil {
        if strings.Contains(err.Error(), "duplicate key") {
            return errors.New("资产已在其他展位展示中")
        }
        return err
    }
    
    // 6. 发布事件 (不再强依赖同步更新 Asset Service 状态)
    go s.publishEvent("gallery.exhibit", exhibition)
    
    return nil
}

4.5 实现清理 Worker (新细节)

职责:定时扫描并删除过期的展示记录。

实现方式

  1. service/ 目录下创建 cleanup_worker.go
  2. 使用 time.Tickercron 库,每分钟运行一次。
  3. 调用 Repository 层 GetExpiredExhibitions
  4. 批量删除过期记录。

示例

func (s *CleanupWorker) Start() {
    ticker := time.NewTicker(1 * time.Minute)
    for range ticker.C {
        expired, _ := s.repo.GetExpiredExhibitions(time.Now().UnixMilli())
        for _, e := range expired {
            s.repo.DeleteExhibition(e.ID)
            // 发送下架事件
            s.publishEvent("gallery.remove.auto", e)
        }
    }
}

验收标准

  • 所有Service方法实现完成
  • 懒加载逻辑正确
  • 优先等级解锁逻辑正确
  • 支持放置到他人展馆
  • 单元测试通过

五、阶段四RPC客户端实现

5.1 Asset Service RPC客户端

文件services/galleryService/client/asset_rpc_client.go

需要的方法

  • GetAssetForRPC:验证资产是否存在且属于当前用户。

注意根据方案A不再需要调用 UpdateAssetExhibitionStatus。展位状态完全由 Gallery Service 维护。


5.2 User Service RPC客户端

文件services/galleryService/client/user_rpc_client.go

需要的方法

  • GetFanProfile:获取粉丝档案(等级、水晶余额)。
  • UpdateCrystalBalance:扣除水晶余额。

5.3 Task Service RPC客户端可选

文件services/galleryService/client/task_rpc_client.go

需要的方法

  • UpdateTaskProgress:更新任务进度。建议在 PlaceAssetCleanupWorker 中触发。

验收标准

  • 所有RPC客户端实现完成
  • 错误处理完善
  • 日志记录完整

六、阶段五Provider层实现

6.1 实现RPC接口

文件services/galleryService/provider/gallery_provider.go

关键点

  1. 从Dubbo attachments提取用户信息
  2. 委托给Service层处理
  3. 统一的错误处理和日志记录

示例

func (p *GalleryProvider) GetMyGallery(ctx context.Context, req *pb.GetMyGalleryRequest) (*pb.GetMyGalleryResponse, error) {
    // 从Dubbo attachments提取用户信息
    userID, starID, err := extractUserInfoFromDubboAttachments(ctx)
    if err != nil {
        return &pb.GetMyGalleryResponse{
            Base: &pbCommon.BaseResponse{
                Code:    pbCommon.StatusCode_STATUS_UNAUTHORIZED,
                Message: "user authentication required",
            },
        }, err
    }
    
    // 调用Service层
    result, err := p.galleryService.GetMyGallery(userID, starID)
    if err != nil {
        return &pb.GetMyGalleryResponse{
            Base: &pbCommon.BaseResponse{
                Code:    appErrors.ToStatusCode(err),
                Message: err.Error(),
            },
        }, err
    }
    
    // 转换响应
    return convertToGetMyGalleryResponse(result), nil
}

验收标准

  • 所有RPC接口实现完成
  • 用户信息提取正确
  • 错误处理完善
  • 日志记录完整

七、阶段六:主程序和配置

7.1 实现main.go

文件services/galleryService/main.go

关键步骤

  1. 初始化日志
  2. 连接数据库
  3. 注册Dubbo服务
  4. 实现优雅关闭

参考services/assetService/main.go

7.2 配置Dubbo

文件services/galleryService/configs/dubbo.yaml

关键配置

  • 服务端口20004参考其他服务
  • 注册中心配置
  • 协议配置

验收标准

  • 服务可以正常启动
  • 可以注册到Dubbo
  • 优雅关闭正常

八、阶段七Gateway层集成

8.1 实现Controller

文件gateway/controller/gallery_controller.go

需要的方法

  • GetMyGallery
  • GetUserGallery
  • PlaceAsset
  • RemoveAsset
  • KickOccupier
  • UnlockSlot

参考gateway/controller/asset_controller.go

8.2 实现DTO

文件

  • gateway/dto/gallery_dto.goDTO定义
  • gateway/dto/gallery_converter.go:转换器

8.3 配置路由

文件gateway/router/router.go

路由配置

galleries := v1.Group("/galleries")
galleries.Use(middleware.AuthMiddleware())
{
    galleries.GET("/me", galleryCtrl.GetMyGallery)
    galleries.GET("/:target_uid", galleryCtrl.GetUserGallery)
    galleries.POST("/place", galleryCtrl.PlaceAsset)
    galleries.POST("/remove", galleryCtrl.RemoveAsset)
    galleries.POST("/me/slots/:slot_id/kick", galleryCtrl.KickOccupier)
}

mygalleries := v1.Group("/mygalleries")
mygalleries.Use(middleware.AuthMiddleware())
{
    mygalleries.GET("", galleryCtrl.GetMyGallery)
}

slots := v1.Group("/galleries")
slots.Use(middleware.AuthMiddleware())
{
    slots.POST("/slots_unlock", galleryCtrl.UnlockSlot)
}

验收标准

  • 所有接口实现完成
  • 路由配置正确
  • DTO转换正确
  • 接口测试通过

九、阶段八:测试和优化

9.1 单元测试

测试覆盖

  • Repository层所有数据访问方法
  • Service层所有业务逻辑方法
  • 懒加载逻辑
  • 优先等级解锁逻辑
  • 放置到他人展馆逻辑

9.2 集成测试

测试场景

  1. 获取我的展馆(懒加载创建初始展位)
  2. 获取他人展馆
  3. 放置资产到自己的展馆
  4. 放置资产到他人的展馆
  5. 下架资产
  6. 踢走占位
  7. 解锁展位(等级解锁)
  8. 解锁展位(水晶购买)

9.3 性能测试

测试项

  • 并发查询展馆
  • 并发放置资产
  • 懒加载性能

9.4 优化

优化项

  • 数据库查询优化
  • 缓存热点数据
  • 批量操作优化

验收标准

  • 单元测试覆盖率 > 80%
  • 集成测试全部通过
  • 性能测试满足要求
  • 代码无linter错误

十、开发检查清单

10.1 代码质量

  • 代码遵循Go语言规范
  • 无linter错误
  • 注释完整
  • 错误处理完善
  • 日志记录完整

10.2 功能完整性

  • 获取我的展馆(懒加载)
  • 获取他人展馆
  • 放置资产(支持他人展馆)
  • 下架资产
  • 踢走占位
  • 解锁展位(优先等级解锁)
  • 规则硬编码在config中

10.3 测试覆盖

  • Repository层单元测试
  • Service层单元测试
  • 集成测试
  • 性能测试

10.4 文档

  • 代码注释完整
  • README文档
  • 实现完成总结文档

十一、常见问题

11.1 懒加载并发冲突

问题:多个并发请求可能导致创建重复记录或报 DB Error。

解决方案

  • 方案 A (推荐):在 Repository 使用 ON CONFLICT DO NOTHING
  • 方案 B:使用 Redis 分布式锁Key 为 gallery_init_{user_id}_{star_id}

11.2 分布式一致性 (资产展示唯一性)

问题:如何保证一个资产不会同时出现在两个展位?

解决方案

  • exhibitions 表对 asset_id 建立唯一索引。这是最强力的物理保证。不再同步更新 Asset Service 的状态字段,实现完全解耦。

11.3 权限边界问题

问题:如何防止用户在他人私有展位放东西?

解决方案

  • 在 Service 层强制校验 BoothSlot.Visibility
  • 只有 Owner 可以往 private 展位放东西。
  • Public 展位允许同明星下的所有粉丝“占领”。

十二、参考资源

12.1 代码参考

  • services/assetService/ - Asset Service实现
  • services/socialService/ - Social Service实现
  • services/userService/ - User Service实现

12.2 文档参考

  • docs/展馆服务设计方案.md - 详细设计方案
  • docs/HTTP REST API接口文档.md - API接口定义
  • docs/微服务架构设计.md - 架构设计

文档版本: v1.0
最后更新: 2026-01-12
维护者: AI Assistant