19 KiB
展馆服务开发流程文档
📋 文档说明
本文档详细说明了展馆服务(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
- 按照设计方案编写 Proto。
- 运行
./scripts/compile-proto.sh生成代码。
2.4 数据库模型与迁移
文件:pkg/models/gallery.go
- 定义
BoothSlot和Exhibition模型。 - 在数据库中执行 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
步骤:
- 检查是否已存在展位(避免重复创建)
- 使用事务创建初始展位(3个)
- 设置初始展位为已解锁(is_enabled = true)
- 设置初始展位为公有(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 (新细节)
职责:定时扫描并删除过期的展示记录。
实现方式:
- 在
service/目录下创建cleanup_worker.go。 - 使用
time.Ticker或cron库,每分钟运行一次。 - 调用 Repository 层
GetExpiredExhibitions。 - 批量删除过期记录。
示例:
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
关键点:
- 从Dubbo attachments提取用户信息
- 委托给Service层处理
- 统一的错误处理和日志记录
示例:
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
关键步骤:
- 初始化日志
- 连接数据库
- 注册Dubbo服务
- 实现优雅关闭
参考:services/assetService/main.go
7.2 配置Dubbo
文件:services/galleryService/configs/dubbo.yaml
关键配置:
- 服务端口:20004(参考其他服务)
- 注册中心配置
- 协议配置
验收标准:
- ✅ 服务可以正常启动
- ✅ 可以注册到Dubbo
- ✅ 优雅关闭正常
八、阶段七:Gateway层集成
8.1 实现Controller
文件:gateway/controller/gallery_controller.go
需要的方法:
GetMyGalleryGetUserGalleryPlaceAssetRemoveAssetKickOccupierUnlockSlot
参考: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
路由配置:
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 集成测试
测试场景:
- 获取我的展馆(懒加载创建初始展位)
- 获取他人展馆
- 放置资产到自己的展馆
- 放置资产到他人的展馆
- 下架资产
- 踢走占位
- 解锁展位(等级解锁)
- 解锁展位(水晶购买)
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