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

714 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 展馆服务开发流程文档
## 📋 文档说明
本文档详细说明了展馆服务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