# 展馆服务设计方案 ## 📋 文档说明 本文档详细说明了展馆服务(Gallery Service)的设计方案,包括服务职责、API设计、数据库设计、业务规则、服务交互等。 **创建日期**: 2026-01-12 **维护者**: AI Assistant **状态**: 🟡 设计方案 **重要说明**: - 本文档中的 API 设计完全参考 `docs/HTTP REST API接口文档.md` - 所有 API 路径、请求参数、响应格式均与 HTTP REST API 接口文档保持一致 - 如有不一致之处,以 HTTP REST API 接口文档为准 --- ## 一、服务概述 ### 1.1 服务定位 **Gallery Service(展馆服务)** 是负责用户展馆管理的核心微服务,包括: - 展馆管理(我的展馆、他人展馆) - 展位管理(槽位列表、解锁/购买展位) - 展品展示(放置、下架、踢走占位) - 展位规则管理(从PostgreSQL加载) ### 1.2 服务职责边界 **Gallery Service 负责:** - ✅ 展馆的创建、查询、管理 - ✅ 展位的创建、解锁、管理 - ✅ 展品的放置、下架、踢走 - ✅ 展位规则的加载和管理 - ✅ 展位占用时长的计算和管理 **Gallery Service 不负责:** - ❌ 资产信息管理(Asset Service) - ❌ 用户信息管理(User Service) - ❌ 粉丝档案管理(User Service) - ❌ 水晶余额扣除(User Service,通过RPC调用) - ❌ 任务进度更新(Task Service,通过事件触发) --- ## 二、核心功能设计 ### 2.1 展馆管理 #### 2.1.1 获取我的展馆 **功能描述**:获取当前用户的展馆信息,包括所有展位及其状态。 **业务规则**: - 每个用户(user_id + star_id)拥有一个展馆 - 展馆初始有3个展位(可配置) - 展位可以解锁/购买(通过等级或水晶) - 展位状态:EMPTY(空)、OCCUPIED(被占用)、LOCKED(未解锁) **API设计**: ``` GET /api/mygalleries ``` **请求参数**:无(从JWT Token中获取user_id和star_id) **响应数据**: ```json { "code": 200, "message": "ok", "data": { "gallery_owner_id": 10000001, "slot_total": 3, "slots": [ { "slot_id": 1, "status": "OCCUPIED", "asset": { "asset_id": "asset_888", "name": "藏品名称", "cover_url": "https://...", "like_count": 12, "remain_time": 5000 } } ] } } ``` **说明**: - 响应格式参考 HTTP REST API 接口文档 - `slot_id`:展位ID - `status`:展位状态(EMPTY、OCCUPIED、LOCKED) - `asset`:展品信息(仅当status为OCCUPIED时存在) - `remain_time`:剩余时间(秒) #### 2.1.2 获取他人展馆 **功能描述**:获取指定用户的展馆信息(用于广场/好友访问)。 **业务规则**: - 只能查看同一明星(star_id)的展馆 - 需要验证粉丝身份一致性 - 返回数据与"我的展馆"相同,但某些操作不可用(如下架、踢走) **API设计**: ``` GET /api/galleries/{target_uid} ``` **请求参数**: - `target_uid` (path): 目标用户ID **响应数据**:同"获取我的展馆" --- ### 2.2 展品展示 #### 2.2.1 在展位展示藏品 **功能描述**:在指定展位放置藏品(可以是自己的展馆或他人的展馆)。 **业务规则**: - 只能放置自己拥有的资产 - 展位必须为空(EMPTY)且已解锁(is_enabled = true) - 如果放置在他人的展馆,需要验证同一明星(star_id) - 放置后,展位被占用,占用时长为4小时(可配置) - 占用期间,展位所有者可以踢走占位 - 一个资产同时只能在一个展位展示 **API设计**: ``` POST /api/galleries/place ``` **请求参数**: ```json { "asset_id": "asset_888", "gallery_owner_id": 10000001, "slot_id": 1 } ``` **说明**: - 参考 HTTP REST API 接口文档,参数中 `slot_id` 在文档中写为 `slot_it`(可能是笔误),实际实现使用 `slot_id` **响应数据**: ```json { "code": 200, "message": "ok", "data": { "status": "OCCUPIED", "occupied_until": "2025-12-30T10:00:00Z", "occupier_uid": 18995036422 } } ``` **说明**: - 响应格式参考 HTTP REST API 接口文档 - `occupied_until`:占用到期时间(ISO 8601格式) - `occupier_uid`:占位者用户ID **业务逻辑流程**: 1. 验证资产是否存在且属于当前用户(调用Asset Service) 2. 验证资产是否已在其他展位展示(查询exhibitions表) 3. 验证展位是否存在且为空(查询booth_slots表) 4. 验证展位是否已解锁(is_enabled = true) 5. 如果放置在他人的展馆,验证同一明星(star_id) 6. 创建展品展示记录(exhibitions表) 7. 更新展位状态(booth_slots表) 8. 更新资产的is_exhibited字段(调用Asset Service) 9. 发布事件:gallery.exhibit(通知Task Service) #### 2.2.2 下架展位藏品 **功能描述**:主动下架展位中的藏品(只有放置者可以下架)。 **业务规则**: - 只有放置者(occupier_uid)可以下架 - 下架后,展位变为空(EMPTY) - 更新资产的is_exhibited字段 **API设计**: ``` POST /api/galleries/remove ``` **请求参数**: ```json { "asset_id": "asset_888", "gallery_owner_uid": 10000001, "slot_id": 1 } ``` **说明**: - 参考 HTTP REST API 接口文档,参数使用 `gallery_owner_uid`(不是 `gallery_owner_id`) - 参数为可选(Body,可选) **响应数据**: ```json { "code": 200, "message": "ok", "data": {} } ``` **说明**: - 响应为空 data,参考 HTTP REST API 接口文档 **业务逻辑流程**: 1. 验证展品展示记录是否存在 2. 验证当前用户是否为放置者(occupier_uid) 3. 删除展品展示记录(exhibitions表) 4. 更新展位状态为EMPTY(booth_slots表) 5. 更新资产的is_exhibited字段(调用Asset Service) 6. 发布事件:gallery.remove(通知Task Service) #### 2.2.3 踢走占位 **功能描述**:展位所有者踢走占位者(只有展位所有者可以操作)。 **业务规则**: - 只有展位所有者(host_profile_id对应的user_id)可以踢走 - 踢走后,展位变为空(EMPTY) - 更新资产的is_exhibited字段 **API设计**: ``` POST /api/galleries/me/slots/{slot_id}/kick ``` **请求参数**: - `slot_id` (path): 展位ID - `confirm` (body, optional): 确认操作 **响应数据**: ```json { "code": 200, "message": "ok", "data": { "slot_id": 2, "status": "EMPTY" } } ``` **业务逻辑流程**: 1. 验证展位是否存在且属于当前用户 2. 验证展位是否被占用 3. 删除展品展示记录(exhibitions表) 4. 更新展位状态为EMPTY(booth_slots表) 5. 更新资产的is_exhibited字段(调用Asset Service) --- ### 2.3 展位管理 #### 2.3.1 解锁/购买新展位 **功能描述**:解锁或购买新的展位(通过等级或水晶)。 **业务规则**: - 初始展位数:3个(可配置) - 解锁方式: - 等级解锁:达到指定等级即可解锁 - 水晶购买:消耗指定数量的水晶 - 解锁后,展位变为可用(is_enabled = true) - 如果使用水晶购买,需要扣除水晶余额(调用User Service) **API设计**: ``` POST /api/galleries/slots_unlock ``` **请求参数**:无(从JWT Token中获取user_id和star_id) **响应数据**: ```json { "code": 200, "message": "ok", "data": { "slot_total": 3, "crystal_balance": 120 } } ``` **说明**: - 参考 HTTP REST API 接口文档,响应包含: - `slot_total`:展位总数 - `crystal_balance`:水晶余额(如果使用水晶购买,显示扣除后的余额) - 解锁后的展位信息可以通过"获取我的展馆"接口查看 **业务逻辑流程**: 1. 查询当前用户的粉丝档案(获取等级、水晶余额) 2. 查询当前展位数和已解锁展位数 3. 查询下一个展位的解锁规则(从config中获取) 4. **优先检查等级解锁**: - 如果用户等级达到要求,直接解锁(不消耗水晶) - 创建新展位(booth_slots表),is_enabled = true 5. **如果等级不够,检查水晶购买**: - 验证水晶余额是否足够 - 如果足够,扣除水晶余额(调用User Service) - 创建新展位(booth_slots表),is_enabled = true 6. 如果等级不够且水晶不足,返回错误提示 --- ## 三、数据库设计 ### 3.1 展位表(booth_slots) **表名**:`booth_slots` **字段定义**: ```sql CREATE TABLE booth_slots ( slot_id BIGSERIAL PRIMARY KEY, host_profile_id BIGINT NOT NULL, -- 展馆所有者(fan_profiles表的id,联合主键) user_id BIGINT NOT NULL, -- 用户ID(冗余字段,便于查询) star_id BIGINT NOT NULL, -- 明星ID(冗余字段,便于查询) slot_index INT NOT NULL, -- 展位序号(1, 2, 3, ...) visibility VARCHAR(20) DEFAULT 'public', -- 可见性:public, private is_enabled BOOLEAN DEFAULT false, -- 是否已解锁 unlock_type VARCHAR(20), -- 解锁方式:level, crystal, free unlock_value INT, -- 解锁条件值(等级或水晶数量) created_at BIGINT NOT NULL, -- 创建时间(毫秒时间戳) updated_at BIGINT NOT NULL, -- 更新时间(毫秒时间戳) -- 索引 UNIQUE KEY uk_host_slot (host_profile_id, slot_index), INDEX idx_user_star (user_id, star_id), INDEX idx_star_enabled (star_id, is_enabled) ); ``` **说明**: - `host_profile_id`:展馆所有者,对应`fan_profiles`表的联合主键(user_id + star_id) - `slot_index`:展位序号,从1开始递增 - `visibility`:可见性标志位,`public`(公有)或 `private`(私有),用于后续功能扩展 - `is_enabled`:是否已解锁,初始展位为true,后续展位为false - `unlock_type`:解锁方式,level(等级)、crystal(水晶)、free(免费) - `unlock_value`:解锁条件值,如等级5或水晶100 ### 3.2 展品展示表(exhibitions) **表名**:`exhibitions` **字段定义**: ```sql CREATE TABLE exhibitions ( id BIGSERIAL PRIMARY KEY, asset_id BIGINT NOT NULL, -- 资产ID slot_id BIGINT NOT NULL, -- 展位ID(外键:booth_slots.slot_id) host_profile_id BIGINT NOT NULL, -- 展馆所有者(冗余字段) occupier_uid BIGINT NOT NULL, -- 占位者用户ID occupier_star_id BIGINT NOT NULL, -- 占位者明星ID(冗余字段) start_time BIGINT NOT NULL, -- 开始时间(毫秒时间戳) expire_at BIGINT NOT NULL, -- 过期时间(毫秒时间戳) created_at BIGINT NOT NULL, -- 创建时间(毫秒时间戳) updated_at BIGINT NOT NULL, -- 更新时间(毫秒时间戳) -- 外键约束 FOREIGN KEY (slot_id) REFERENCES booth_slots(slot_id) ON DELETE CASCADE, -- 索引 UNIQUE KEY uk_asset (asset_id), -- 一个资产同时只能在一个展位 INDEX idx_slot (slot_id), INDEX idx_host (host_profile_id), INDEX idx_occupier (occupier_uid, occupier_star_id), INDEX idx_expire (expire_at) -- 用于定时清理过期展品 ); ``` **说明**: - `asset_id`:资产ID,唯一约束,确保一个资产同时只能在一个展位 - `slot_id`:展位ID,外键关联`booth_slots`表 - `host_profile_id`:展馆所有者,冗余字段,便于查询 - `occupier_uid`:占位者用户ID - `expire_at`:过期时间,用于定时清理过期展品 **注意**: - 规则表(gallery_rules)暂不实现,规则直接硬编码在config文件中 - 后续版本会引入规则表进行统一规则配置 --- ## 四、业务规则设计 ### 4.1 展位初始化规则 ✅ **已确认:懒加载** **规则**: - 首次查询展馆时,自动创建初始展位(懒加载) - 初始展位数量:3个(硬编码在config中) - 初始展位全部解锁(is_enabled = true) - 初始展位全部为公有(visibility = 'public') - 展位序号从1开始递增 **实现方式**: - 在Gallery Service首次查询"获取我的展馆"时,检查是否存在展位 - 如果不存在,自动创建初始展位(3个) - 使用事务确保原子性 **优势**: - 解耦,不需要User Service调用Gallery Service - 减少服务间依赖 - 按需创建,节省资源 ### 4.2 展位解锁规则 ✅ **已确认:优先等级解锁** **规则**: - 第1-3个展位:免费解锁(初始展位) - 第4个展位:等级5或水晶100 - 第5个展位:等级6或水晶200 - 第6个展位:等级7或水晶300 - ...(硬编码在config中,后续版本会引入规则表) **解锁方式**: - **优先等级解锁**:优先检查是否达到指定等级,如果达到则免费解锁,无需消耗水晶 - **水晶购买**:如果等级不够,可以使用水晶购买,消耗指定数量的水晶 **实现逻辑**: 1. 查询下一个展位的解锁规则(从config中获取) 2. 检查用户等级是否达到要求 3. 如果等级达到,直接解锁(不消耗水晶) 4. 如果等级不够,检查水晶余额是否足够 5. 如果水晶足够,扣除水晶并解锁 6. 如果都不满足,返回错误提示 ### 4.3 展位占用规则 **规则**: - 占用时长:4小时(可配置) - 占用期间,展位所有者可以踢走占位 - 占用到期后,自动下架(需要定时任务) - 一个资产同时只能在一个展位展示 **需要确认**:❓ 占用到期后的处理? - 选项A:自动下架,展位变为空(需要定时任务) - 选项B:保持占用,但标记为过期(需要前端显示过期状态) ### 4.4 展位放置规则 ✅ **已确认:可以放置到他人展馆** **规则**: - 只能放置自己拥有的资产 - 展位必须为空(EMPTY)且已解锁(is_enabled = true) - **可以放置在他人的展馆**,但需要验证同一明星(star_id) - 一个资产同时只能在一个展位展示 - 放置后,展位被占用,占用时长为4小时(硬编码在config中) - 占用期间,展位所有者可以踢走占位 **实现逻辑**: 1. 验证资产是否存在且属于当前用户(调用Asset Service) 2. 验证资产是否已在其他展位展示(查询exhibitions表) 3. 验证展位是否存在且为空(查询booth_slots表) 4. 验证展位是否已解锁(is_enabled = true) 5. 如果放置在他人的展馆,验证同一明星(star_id) 6. 创建展品展示记录(exhibitions表) 7. 更新展位状态(booth_slots表) 8. 更新资产的is_exhibited字段(调用Asset Service) 9. 发布事件:gallery.exhibit(通知Task Service) --- ## 五、服务间交互 ### 5.1 与 Asset Service 的交互 #### 5.1.1 验证资产是否存在且属于当前用户 **RPC接口**: ```protobuf rpc GetAssetForRPC(GetAssetForRPCRequest) returns (GetAssetForRPCResponse); ``` **使用场景**: - 放置资产前验证资产是否存在 - 验证资产是否属于当前用户 - 验证资产是否已在其他展位展示 **请求字段**: - `asset_id`:资产ID - `user_id`:用户ID - `star_id`:明星ID **响应字段**: - `asset_id`:资产ID - `owner_uid`:所有者用户ID - `is_exhibited`:是否已在展位展示 #### 5.1.2 更新资产展馆状态 **RPC接口**: ```protobuf rpc UpdateAssetExhibitionStatus(UpdateAssetExhibitionStatusRequest) returns (UpdateAssetExhibitionStatusResponse); ``` **使用场景**: - 放置资产时,更新`is_exhibited = true` - 下架资产时,更新`is_exhibited = false` **请求字段**: - `asset_id`:资产ID - `is_exhibited`:是否在展位展示 ### 5.2 与 User Service 的交互 #### 5.2.1 获取粉丝档案信息 **RPC接口**: ```protobuf rpc GetFanProfile(GetFanProfileRequest) returns (GetFanProfileResponse); ``` **使用场景**: - 解锁展位时,获取用户等级和水晶余额 - 验证用户权限 **请求字段**: - `user_id`:用户ID - `star_id`:明星ID **响应字段**: - `user_id`:用户ID - `star_id`:明星ID - `level`:等级 - `crystal_balance`:水晶余额 #### 5.2.2 扣除水晶余额 **RPC接口**: ```protobuf rpc UpdateCrystalBalance(UpdateCrystalBalanceRequest) returns (UpdateCrystalBalanceResponse); ``` **使用场景**: - 使用水晶购买展位时,扣除水晶余额 **请求字段**: - `user_id`:用户ID - `star_id`:明星ID - `delta`:变化量(负数表示扣除) ### 5.3 与 Task Service 的交互 #### 5.3.1 发布事件(可选) **事件类型**: - `gallery.exhibit`:展品上架事件 - `gallery.remove`:展品下架事件 - `gallery.unlock`:展位解锁事件 **实现方式**: - 直接RPC调用Task Service(推荐) - 或使用消息队列(RocketMQ/Kafka/RabbitMQ) **需要确认**:❓ 事件通知方式? - 选项A:直接RPC调用(简单,推荐) - 选项B:消息队列(解耦,但需要额外基础设施) --- ## 六、实现架构 ### 6.1 服务结构 ``` 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 # 实现完成总结 ``` ### 6.2 配置层(Config) **文件**:`config/gallery_config.go` **配置项**: ```go type GalleryConfig struct { // 规则配置(硬编码,后续版本会引入规则表) Rules *GalleryRules // RPC客户端配置 AssetServiceURL string UserServiceURL string TaskServiceURL string // 数据库配置 Database *DatabaseConfig } type GalleryRules struct { // 初始展位数(默认:3) InitialSlotCount int // 抢展位时长(秒,默认:14400,即4小时) GrabSlotDuration int64 // 按等级解锁成本(slot_index -> level) // 例如:第4个展位需要等级5,第5个展位需要等级6 UnlockLevelBySlot map[int]int // 按水晶解锁成本(slot_index -> crystal_cost) // 例如:第4个展位需要水晶100,第5个展位需要水晶200 UnlockCrystalBySlot map[int]int // 最大展位数(默认:10) MaxSlotCount int } ``` **配置示例**(参考其他service的实现方式): ```go // config/gallery_config.go package config var ( // GalleryRules 展馆规则配置(硬编码) GalleryRules = &GalleryRulesConfig{ InitialSlotCount: 3, GrabSlotDuration: 14400, // 4小时 // 按等级解锁:第4个展位需要等级5,第5个展位需要等级6,以此类推 UnlockLevelBySlot: map[int]int{ 4: 5, // 第4个展位需要等级5 5: 6, // 第5个展位需要等级6 6: 7, // 第6个展位需要等级7 7: 8, // 第7个展位需要等级8 8: 9, // 第8个展位需要等级9 9: 10, // 第9个展位需要等级10 10: 11, // 第10个展位需要等级11 }, // 按水晶解锁:第4个展位需要水晶100,第5个展位需要水晶200,以此类推 UnlockCrystalBySlot: map[int]int{ 4: 100, // 第4个展位需要水晶100 5: 200, // 第5个展位需要水晶200 6: 300, // 第6个展位需要水晶300 7: 400, // 第7个展位需要水晶400 8: 500, // 第8个展位需要水晶500 9: 600, // 第9个展位需要水晶600 10: 700, // 第10个展位需要水晶700 }, MaxSlotCount: 10, } ) ``` ### 6.3 Repository层 **职责**: - 展馆数据访问(CRUD操作) - 展位数据访问 - 展品展示数据访问 **主要方法**: ```go // 展馆相关 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) ``` **注意**: - 规则配置硬编码在config文件中,不需要从数据库加载 - 后续版本会引入规则表,届时会添加规则加载方法 ### 6.4 Service层 **职责**: - 展馆业务逻辑 - 展位业务逻辑 - 展品业务逻辑 - 规则管理 **主要方法**: ```go // 展馆服务 GetMyGallery(userID, starID int64) (*GalleryResponse, error) GetUserGallery(targetUID, starID int64) (*GalleryResponse, error) // 展位服务 UnlockSlot(userID, starID int64) (*UnlockSlotResponse, error) // 展品服务 PlaceAsset(userID, starID int64, req *PlaceAssetRequest) (*PlaceAssetResponse, error) RemoveAsset(userID, starID int64, req *RemoveAssetRequest) error KickOccupier(userID, starID int64, slotID int64) error ``` ### 6.5 Provider层 **职责**: - 实现Proto定义的RPC接口 - 从Dubbo attachments提取用户信息 - 委托给Service层处理 **主要方法**: ```go // 实现GalleryServiceHandler接口 GetMyGallery(ctx context.Context, req *pb.GetMyGalleryRequest) (*pb.GetMyGalleryResponse, error) GetUserGallery(ctx context.Context, req *pb.GetUserGalleryRequest) (*pb.GetUserGalleryResponse, error) PlaceAsset(ctx context.Context, req *pb.PlaceAssetRequest) (*pb.PlaceAssetResponse, error) RemoveAsset(ctx context.Context, req *pb.RemoveAssetRequest) (*pb.RemoveAssetResponse, error) KickOccupier(ctx context.Context, req *pb.KickOccupierRequest) (*pb.KickOccupierResponse, error) UnlockSlot(ctx context.Context, req *pb.UnlockSlotRequest) (*pb.UnlockSlotResponse, error) ``` --- ## 七、Proto接口定义 ### 7.1 创建Proto文件 **文件**:`proto/gallery.proto` **定义**: ```protobuf syntax = "proto3"; package topfans.gallery; import "google/api/annotations.proto"; import "common/common.proto"; option go_package = "github.com/topfans/backend/pkg/proto/gallery"; // 展馆服务 service GalleryService { // 获取我的展馆 rpc GetMyGallery(GetMyGalleryRequest) returns (GetMyGalleryResponse) { option (google.api.http) = { get: "/api/mygalleries" }; } // 获取他人展馆 rpc GetUserGallery(GetUserGalleryRequest) returns (GetUserGalleryResponse) { option (google.api.http) = { get: "/api/galleries/{target_uid}" }; } // 在展位展示藏品 rpc PlaceAsset(PlaceAssetRequest) returns (PlaceAssetResponse) { option (google.api.http) = { post: "/api/galleries/place" body: "*" }; } // 下架展位藏品 rpc RemoveAsset(RemoveAssetRequest) returns (RemoveAssetResponse) { option (google.api.http) = { post: "/api/galleries/remove" body: "*" }; } // 踢走占位 rpc KickOccupier(KickOccupierRequest) returns (KickOccupierResponse) { option (google.api.http) = { post: "/api/galleries/me/slots/{slot_id}/kick" body: "*" }; } // 解锁/购买新展位 rpc UnlockSlot(UnlockSlotRequest) returns (UnlockSlotResponse) { option (google.api.http) = { post: "/api/galleries/slots_unlock" body: "*" }; } } // 请求和响应消息定义 message GetMyGalleryRequest {} message GetMyGalleryResponse { topfans.common.BaseResponse base = 1; GalleryData data = 2; } message GetUserGalleryRequest { int64 target_uid = 1; } message GetUserGalleryResponse { topfans.common.BaseResponse base = 1; GalleryData data = 2; } message PlaceAssetRequest { int64 asset_id = 1; int64 gallery_owner_id = 2; // 注意:HTTP文档中使用 gallery_owner_id int64 slot_id = 3; // 注意:HTTP文档中写为 slot_it(可能是笔误),实际使用 slot_id } message PlaceAssetResponse { topfans.common.BaseResponse base = 1; PlaceAssetData data = 2; } message RemoveAssetRequest { int64 asset_id = 1; int64 gallery_owner_uid = 2; // 注意:使用 gallery_owner_uid(不是 gallery_owner_id) int64 slot_id = 3; } message RemoveAssetResponse { topfans.common.BaseResponse base = 1; } message KickOccupierRequest { int64 slot_id = 1; bool confirm = 2; } message KickOccupierResponse { topfans.common.BaseResponse base = 1; KickOccupierData data = 2; } message UnlockSlotRequest {} message UnlockSlotResponse { topfans.common.BaseResponse base = 1; UnlockSlotData data = 2; } // 数据模型 message GalleryData { int64 gallery_owner_id = 1; int32 slot_total = 2; repeated SlotInfo slots = 3; } message SlotInfo { int64 slot_id = 1; int32 slot_index = 2; string status = 3; // EMPTY, OCCUPIED, LOCKED bool is_enabled = 4; AssetInfo asset = 5; int64 occupier_uid = 6; int64 occupied_at = 7; int64 expire_at = 8; UnlockCondition unlock_condition = 9; } message AssetInfo { int64 asset_id = 1; string name = 2; string cover_url = 3; int32 like_count = 4; int64 remain_time = 5; // 剩余时间(秒) } message UnlockCondition { string type = 1; // level, crystal int32 value = 2; } message PlaceAssetData { string status = 1; // OCCUPIED string occupied_until = 2; // ISO 8601格式 int64 occupier_uid = 3; // 占位者用户ID } message KickOccupierData { string slot_id = 1; // 注意:返回字符串格式(如 "slot_2") string status = 2; // EMPTY } message UnlockSlotData { int32 slot_total = 1; // 展位总数 int64 crystal_balance = 2; // 水晶余额(如果使用水晶购买,显示扣除后的余额) // 注意:解锁后的展位信息可以通过"获取我的展馆"接口查看,此处不返回 } ``` --- ## 八、需要确认的问题 ### 8.1 展位初始化时机 ✅ **已确认:懒加载** **决定**:首次查询时自动创建(懒加载) **实现方式**: - 在Gallery Service首次查询"获取我的展馆"时,检查是否存在展位 - 如果不存在,自动创建初始展位(3个) - 使用事务确保原子性 **优势**: - 解耦,不需要User Service调用Gallery Service - 减少服务间依赖 - 按需创建,节省资源 --- ### 8.2 解锁方式优先级 ✅ **已确认:优先等级解锁** **决定**:优先使用等级解锁,如果等级不够,可以使用水晶购买 **实现逻辑**: 1. 查询下一个展位的解锁规则(从config中获取) 2. 检查用户等级是否达到要求 3. 如果等级达到,直接解锁(不消耗水晶) 4. 如果等级不够,检查水晶余额是否足够 5. 如果水晶足够,扣除水晶并解锁 6. 如果都不满足,返回错误提示 **优势**: - 鼓励用户提升等级 - 提供灵活性,等级不够时可以使用水晶 --- ### 8.3 占用到期后的处理 ❓ **问题**:展位占用到期后如何处理? **选项A**:自动下架,展位变为空(需要定时任务) - 优点:自动清理,展位可以重新使用 - 缺点:需要定时任务 **选项B**:保持占用,但标记为过期(需要前端显示过期状态) - 优点:不需要定时任务 - 缺点:需要前端处理过期状态 **推荐**:选项A(自动下架)+ 选项B(前端显示过期状态,定时任务异步清理) --- ### 8.4 是否可以放置在他人的展馆 ✅ **已确认:可以放置** **决定**:可以放置在他人的展馆(需要验证同一明星) **实现逻辑**: - 验证资产是否存在且属于当前用户 - 验证展位是否存在且为空 - 如果放置在他人的展馆,验证同一明星(star_id) - 创建展品展示记录 **优势**: - 增加社交互动 - 提升用户参与度 --- ### 8.5 事件通知方式 ❓ **问题**:如何通知Task Service更新任务进度? **选项A**:直接RPC调用(推荐) - 优点:简单,不需要额外基础设施 - 缺点:服务间耦合 **选项B**:消息队列(RocketMQ/Kafka/RabbitMQ) - 优点:解耦,异步处理 - 缺点:需要额外基础设施 **推荐**:选项A(直接RPC调用),参考Asset Service的实现 --- ### 8.6 展位最大数量 ❓ **问题**:展位最大数量是多少? **选项A**:10个(可配置) **选项B**:20个(可配置) **选项C**:无限制(可配置) **推荐**:选项A(10个),后续可根据需求调整 --- ### 8.7 展位占用时长 ❓ **问题**:展位占用时长是多少? **选项A**:4小时(可配置) **选项B**:24小时(可配置) **选项C**:永久(可配置) **推荐**:选项A(4小时),后续可根据需求调整 --- ## 九、实现计划 ### 9.1 阶段一:Proto接口定义和数据库设计 **任务**: 1. 创建`proto/gallery.proto`文件 2. 定义数据库表结构(booth_slots、exhibitions) 3. 创建数据库模型(`pkg/models/gallery.go`) 4. 编译Proto文件 **注意**: - 暂不创建规则表(gallery_rules),规则硬编码在config文件中 - 后续版本会引入规则表 **预计工作量**:2-3小时 --- ### 9.2 阶段二:Repository层实现 **任务**: 1. 实现`gallery_repository.go` 2. 实现展馆数据访问方法 3. 实现展位数据访问方法(包括懒加载创建初始展位) 4. 实现展品数据访问方法 5. 编写单元测试 **注意**: - 暂不实现规则数据加载方法,规则硬编码在config文件中 - 懒加载逻辑:首次查询时自动创建初始展位 **预计工作量**:4-6小时 --- ### 9.3 阶段三:Service层实现 **任务**: 1. 实现`gallery_service.go` 2. 实现`slot_service.go` 3. 实现`exhibition_service.go` 4. 实现业务逻辑和规则验证 5. 编写单元测试 **预计工作量**:6-8小时 --- ### 9.4 阶段四:RPC客户端实现 **任务**: 1. 实现`asset_rpc_client.go` 2. 实现`user_rpc_client.go` 3. 实现`task_rpc_client.go`(可选) 4. 实现错误处理和日志记录 **预计工作量**:2-3小时 --- ### 9.5 阶段五:Provider层实现 **任务**: 1. 实现`gallery_provider.go` 2. 实现所有RPC接口 3. 从Dubbo attachments提取用户信息 4. 委托给Service层处理 **预计工作量**:3-4小时 --- ### 9.6 阶段六:主程序和配置 **任务**: 1. 实现`main.go` 2. 实现`config/gallery_config.go`(硬编码规则配置) 3. 配置`configs/dubbo.yaml` 4. 实现优雅关闭 **注意**: - 规则配置硬编码在`config/gallery_config.go`中,参考其他service的实现方式 - 暂不实现规则加载机制(从数据库加载),后续版本会引入 **预计工作量**:2-3小时 --- ### 9.7 阶段七:Gateway层集成 **任务**: 1. 实现`gateway/controller/gallery_controller.go` 2. 实现`gateway/dto/gallery_dto.go` 3. 实现`gateway/dto/gallery_converter.go` 4. 配置路由 5. 测试接口 **预计工作量**:3-4小时 --- ### 9.8 阶段八:测试和优化 **任务**: 1. 单元测试 2. 集成测试 3. 性能测试 4. 优化和修复 **预计工作量**:4-6小时 --- **总预计工作量**:26-37小时(约3-5个工作日) --- ## 十、总结 ### 10.1 设计要点 1. **服务职责清晰**:展馆服务专注于展馆、展位、展品的管理 2. **数据隔离**:使用`user_id + star_id`进行数据隔离 3. **规则硬编码**:展位规则硬编码在config文件中,与其他service实现方式一致 4. **服务间解耦**:通过RPC调用其他服务,不直接访问其他服务的数据库 5. **扩展性强**:支持后续功能扩展(如展位装饰、展品排序、私有/公有展位等) 6. **展位可见性**:展位支持私有/公有标志位,便于后续功能扩展 ### 10.2 关键设计决策 1. **展位初始化**:✅ 懒加载方式,首次查询时自动创建 2. **解锁方式**:✅ 优先使用等级解锁,不够可以使用水晶购买 3. **他人展馆**:✅ 可以放置在他人的展馆,增加社交互动 4. **规则管理**:✅ 规则硬编码在config文件中,后续版本会引入规则表 5. **展位可见性**:✅ 展位支持私有/公有标志位(visibility字段) 6. **事件通知**:直接RPC调用Task Service,简单高效 ### 10.3 下一步行动 1. **确认问题**:与产品/业务确认上述需要确认的问题 2. **细化设计**:根据确认结果,细化数据库设计和业务逻辑 3. **开始实现**:按照实现计划,逐步实现各个阶段 4. **测试验证**:完成实现后,进行充分的测试验证 --- **文档版本**: v1.0 **最后更新**: 2026-01-12 **维护者**: AI Assistant