# 展馆服务开发流程文档 ## 📋 文档说明 本文档详细说明了展馆服务(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`: ```bash 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 依赖地址。 **代码示例**: ```go 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. 定义 `BoothSlot` 和 `Exhibition` 模型。 2. 在数据库中执行 SQL 脚本创建表和 `UNIQUE` 索引。 --- ## 三、阶段二:Repository 层实现 ### 3.1 创建Repository接口 **文件**:`services/galleryService/repository/gallery_repository.go` **主要方法**: ```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') **示例**: ```go 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`:获取他人展馆 **懒加载实现**: ```go 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`:解锁/购买新展位 **解锁逻辑**(优先等级解锁): ```go 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实现): ```go 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.Ticker` 或 `cron` 库,每分钟运行一次。 3. 调用 Repository 层 `GetExpiredExhibitions`。 4. 批量删除过期记录。 **示例**: ```go 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`:更新任务进度。建议在 `PlaceAsset` 和 `CleanupWorker` 中触发。 **验收标准**: - ✅ 所有RPC客户端实现完成 - ✅ 错误处理完善 - ✅ 日志记录完整 --- ## 六、阶段五:Provider层实现 ### 6.1 实现RPC接口 **文件**:`services/galleryService/provider/gallery_provider.go` **关键点**: 1. 从Dubbo attachments提取用户信息 2. 委托给Service层处理 3. 统一的错误处理和日志记录 **示例**: ```go 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.go`:DTO定义 - `gateway/dto/gallery_converter.go`:转换器 ### 8.3 配置路由 **文件**:`gateway/router/router.go` **路由配置**: ```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