# 灵感瀑布流(Inspiration Flow)设计文档 > **创建日期:** 2026-04-28 > **更新日期:** 2026-04-29 > **项目:** TopFans 横向瀑布流藏品展示 > **服务:** galleryService (Go Dubbo-go) > **状态:** 设计中 --- ## 一、设计目标 横向瀑布流展示该 star_id 下所有用户展出的藏品,支持**随机展示**、**双向横向无限滚动**、按类型过滤。 **核心特点:** - 每次查询返回随机顺序的藏品 - 支持双向横向无限滚动: - 向右滚动:加载更多新数据(发现新内容) - 向左滚动:加载历史数据(回看) - 支持按藏品类型过滤(badge/poster/original/all) - 会话级缓存,刷新后数据重新随机 **无限滚动实现方式:** 使用游标分页(Cursor-based Pagination),前端滚动到边缘时携带 `cursor` 参数加载下一批数据。 --- ## 二、数据来源 **主表:** Exhibition(展品展示表) **关联表:** - Asset(资产表)- 用于获取藏品名称、封面、点赞数 - FanProfile(粉丝档案表)- 用于获取展出者昵称 **筛选条件:** - `occupier_star_id = ?` (当前用户 star_id) - `expire_at > now` (未过期) - `deleted_at IS NULL` (未删除) --- ## 三、API 设计 ### 3.1 获取灵感瀑布藏品列表 ``` GET /api/v1/inspiration-flow ``` **Query 参数:** | 参数 | 类型 | 必填 | 默认值 | 说明 | |------|------|------|--------|------| | cursor | string | 否 | 空 | 游标(首次请求为空) | | direction | string | 否 | right | 滚动方向:right(加载新数据)/ left(加载历史) | | limit | int | 否 | 10 | 每页数量(最大 20,移动端优化) | | type | string | 否 | all | 过滤类型:badge/poster/original/all | **HTTP 响应:** ```json { "code": 200, "message": "ok", "data": { "items": [ { "asset_id": 123, "name": "藏品名称", "cover_url": "https://xxx.com/cover.png", "like_count": 100, "owner_nickname": "粉丝昵称" } ], "cursor": "eyJsaW1pdCI6MTB9", "has_more": true } } ``` **响应字段说明:** | 字段 | 类型 | 说明 | |------|------|------| | items | array | 藏品列表 | | cursor | string | 下次请求的游标,base64 编码的 JSON | | has_more | bool | 是否还有更多数据 | **错误码:** | code | 说明 | |------|------| | 200 | 成功 | | 401 | 用户认证失败 | | 500 | 服务器内部错误 | --- ## 四、Proto 定义 ### 4.1 Request / Response ```protobuf // 获取灵感瀑布藏品列表请求 message GetInspirationFlowRequest { string cursor = 1; // 游标(首次请求为空) string direction = 2; // 滚动方向:right(加载新数据)/ left(加载历史) int32 limit = 3; // 每页数量(默认10,最大20) string type = 4; // 过滤类型:badge/poster/original/all(默认all) } // 获取灵感瀑布藏品列表响应 message GetInspirationFlowResponse { topfans.common.BaseResponse base = 1; InspirationFlowData data = 2; } // 灵感瀑布数据 message InspirationFlowData { repeated InspirationFlowItem items = 1; // 藏品列表 string cursor = 2; // 下次请求的游标 bool has_more = 3; // 是否有更多 } // 灵感瀑布藏品项 message InspirationFlowItem { int64 asset_id = 1; // 资产ID string name = 2; // 藏品名称 string cover_url = 3; // 封面图URL int32 like_count = 4; // 点赞数 string owner_nickname = 5; // 展出者昵称 } ``` ### 4.2 Service 方法 在 GalleryService 中新增方法: ```protobuf // 展馆服务 service GalleryService { // ... 现有方法 ... // 获取灵感瀑布藏品列表 rpc GetInspirationFlow(GetInspirationFlowRequest) returns (GetInspirationFlowResponse) { option (google.api.http) = { get: "/api/v1/inspiration-flow" }; } } ``` --- ## 五、核心逻辑 ### 5.1 随机查询实现 **核心需求:** 每次查询返回随机顺序的藏品数据,而不是固定顺序。 **实现方式:** 使用 PostgreSQL 的 `ORDER BY RANDOM()` 实现随机排序。 **为什么选择 ORDER BY RANDOM():** 1. **实现简单**:一条 SQL 搞定,无需应用层处理 2. **数据量适配良好**:在几千到几万条数据时性能可接受(< 100ms) 3. **接口稳定**:便于后期扩展优化策略 **性能说明:** | 数据量 | RANDOM() 性能 | 推荐程度 | |--------|--------------|---------| | < 1万条 | < 50ms | 强烈推荐 | | 1-5万条 | 50-100ms | 推荐 | | 5-10万条 | 100-500ms | 可接受 | | > 10万条 | 开始变慢 | 需要优化 | > 注:后期数据量超过 10 万条时,可考虑切换为"区间采样"策略,详见本章 5.5 节扩展说明。 --- ### 5.2 随机 offset 分页设计 **核心思路:** 每次请求都是独立的随机排序,随机生成 offset 值,而不是依赖游标累加 offset。 **游标结构(JSON,base64 编码):** ```json { "limit": 10 } ``` > **说明:** 游标只记录 limit,不需要记录 offset(因为每次 offset 都是随机生成的) **为什么用随机 offset:** 1. **数据变化无影响**:每次都是全新随机,数据变化不影响展示 2. **性能稳定**:offset 从 0 开始,不存在深分页性能问题 3. **实现简单**:无需处理数据变化时的缓存问题 **游标的行为说明:** - 每次请求 offset 都是随机生成的(0 ~ max(0, total-limit)) - 同一会话内滚动时,顺序可能跳变(这是预期行为) - 用户刷新页面后,看到全新的随机顺序 > **业务说明:** 由于每次请求都是独立随机,滚动加载过程中顺序可能跳变。这是预期行为,用户每次刷新看到的是不同顺序,符合"随机展示"的需求。 --- ### 5.3 查询逻辑 **实现流程:** ``` 1. 查询该 star_id 下有效展品的总数 2. 随机生成 offset 值(0 ~ max(0, total-limit)) 3. 执行带随机 offset 的查询 ``` **SQL 查询:** ```sql -- 1. 先查询总数 SELECT COUNT(*) FROM exhibitions e WHERE e.occupier_star_id = ? AND e.expire_at > ? AND e.deleted_at IS NULL; -- 2. 应用层生成随机 offset,然后查询 SELECT e.asset_id, a.name, a.cover_url, a.like_count, fp.nickname as owner_nickname FROM exhibitions e JOIN assets a ON e.asset_id = a.id JOIN fan_profiles fp ON e.occupier_uid = fp.user_id AND e.occupier_star_id = fp.star_id WHERE e.occupier_star_id = ? AND e.expire_at > ? AND e.deleted_at IS NULL AND a.status = 1 AND a.is_active = true AND (? = 'all' OR a.material_type = ?) ORDER BY RANDOM() LIMIT ? OFFSET ?; -- offset 由应用层随机生成 ``` **参数说明:** - `? = star_id` (当前用户 star_id) - `? = now` (当前时间戳) - `? = type` (过滤类型) - `? = limit` (每页数量) - `? = offset` (随机生成的偏移量,非固定值) --- ### 5.4 游标编解码 **说明:** 由于每次请求的 offset 是随机生成的,游标只需要记录 limit。 **编码(服务端):** ```go limit := 10 // 默认值,实际从请求或配置获取 cursor := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"limit":%d}`, limit))) ``` **解码(服务端):** ```go decoded, _ := base64.StdEncoding.DecodeString(cursor) var cursorData map[string]int json.Unmarshal(decoded, &cursorData) limit := cursorData["limit"] // offset 由每次请求随机生成,无需从游标获取 ``` **前端使用:** ```javascript // 首次请求 fetch('/api/v1/inspiration-flow?limit=10&type=all') // 向右滚动(加载新数据) fetch('/api/v1/inspiration-flow?cursor=eyJsaW1pdCI6MTB9&limit=10&type=all&direction=right') // 向左滚动(加载历史) fetch('/api/v1/inspiration-flow?cursor=eyJsaW1pdCI6MTB9&limit=10&type=all&direction=left') ``` --- ### 5.5 双向滚动实现 **核心思路:** 后端维护会话级已展示数据缓存,实现双向滚动时完全避免重复。 **滚动方向定义:** - `direction: "right"` - 向右滚动,加载新数据(发现新内容) - `direction: "left"` - 向左滚动,加载历史数据(回看) **游标结构(JSON,base64 编码):** ```json { "limit": 10 } ``` > **说明:** direction 是独立的 query 参数,不包含在游标中。游标只用于分页控制。 **缓存结构(Redis):** ``` Key: inspiration_flow:{star_id}:{session_id} Type: Hash Fields: - display_order: [1, 5, 3, 9, 2, 4, ...] # 展示顺序 - history: {1: data1, 5: data5, 3: data3, ...} # 历史数据详情(方便回看) TTL: 30分钟(无操作自动清理) ``` **向右滚动处理流程:** ``` 1. 前端请求:direction=right 2. 后端从 Redis 获取已展示ID集合 3. 后端执行随机查询,排除已展示ID SELECT ... FROM exhibitions WHERE id NOT IN (已展示ID) ORDER BY RANDOM() LIMIT ? 4. 返回新数据,并记录到缓存 5. 前端追加展示 ``` **向左滚动处理流程:** ``` 1. 前端请求:direction=left, offset=? 2. 后端从 Redis 获取 display_order 3. 后端根据 offset 从历史中分页返回 4. 前端在左侧插入展示 ``` **SQL 排除已展示ID查询:** ```sql SELECT e.asset_id, a.name, a.cover_url, a.like_count, fp.nickname as owner_nickname FROM exhibitions e JOIN assets a ON e.asset_id = a.id JOIN fan_profiles fp ON e.occupier_uid = fp.user_id AND e.occupier_star_id = fp.star_id WHERE e.occupier_star_id = ? AND e.expire_at > ? AND e.deleted_at IS NULL AND e.id NOT IN (已展示ID列表) AND a.status = 1 AND a.is_active = true AND (? = 'all' OR a.material_type = ?) ORDER BY RANDOM() LIMIT ?; ``` > **注意:** 当已展示数据量很大时,`NOT IN` 查询性能会下降。需要在适当时机清理已展示缓存(建议 TTL 30分钟或达到一定数量后刷新随机顺序)。 --- ### 5.6 扩展说明:数据量大时的优化策略 **触发条件:** 当展品数据量超过 10 万条,`ORDER BY RANDOM()` 性能开始明显下降时。 **优化方案:区间采样** **思路:** 把数据按 ID 区间分成多个桶,随机选择桶后在该桶内读取数据,应用层再对结果进行随机打乱 **优点:** - 查询性能稳定,不随数据量增长而显著下降 - 能保证随机分布,每个数据都有机会被展示 - 跨桶采样,随机性更好 **适用场景:** - 数据量大(10 万以上) - 需要保证随机分布的均匀性 **实现示意:** ```go // 1. 获取该 star_id 下有效展品的 ID 范围 minID, maxID := "SELECT MIN(id), MAX(id) FROM exhibitions WHERE occupier_star_id = ? AND expire_at > ? AND deleted_at IS NULL" // 2. 计算桶信息(假设每个桶 10000 条) bucketSize := int64(10000) totalBuckets := (maxID - minID) / bucketSize + 1 // 3. 随机选择 1 个桶 randomBucket := random.Int63n(totalBuckets) bucketStartID := minID + randomBucket*bucketSize bucketEndID := bucketStartID + bucketSize // 4. 在桶内查询所有有效 ID(数据量小,随机开销可忽略) bucketIDs := "SELECT id FROM exhibitions WHERE id BETWEEN ? AND ? AND occupier_star_id = ? AND expire_at > ? AND deleted_at IS NULL ORDER BY id" // 5. 应用层对 bucketIDs 随机打乱,取前 limit 个 shuffle(bucketIDs) selectedIDs := bucketIDs[:limit] // 6. 根据 selectedIDs 查询完整数据 "SELECT ... FROM exhibitions WHERE id IN (?)", selectedIDs ``` --- #### 扩展接口设计(预留) 两种策略可以抽象成统一的接口,便于后期切换: ```go // 随机策略接口 type RandomStrategy interface { // GetRandomAssetIDs 获取随机的展品 ID 列表 GetRandomAssetIDs(ctx context.Context, starID string, limit int) ([]int64, error) // Name 返回策略名称 Name() string } // 策略1:随机排序(当前使用,数据量 < 10 万时推荐) type RandomOrderStrategy struct{} func (s *RandomOrderStrategy) GetRandomAssetIDs(ctx context.Context, starID string, limit int) ([]int64, error) { // 1. 获取有效展品总数 total, err := repo.CountValidAssets(starID) if err != nil { return nil, err } if total == 0 { return []int64{}, nil } // 2. 随机生成 offset(0 ~ max(0, total-limit)) maxOffset := total - int64(limit) if maxOffset < 0 { maxOffset = 0 } randomOffset := random.Int63n(maxOffset + 1) // +1 是因为 Int63n(0) 会报错 // 3. 执行随机排序查询 return repo.GetRandomAssetsByOrder(starID, int(randomOffset), limit) } func (s *RandomOrderStrategy) Name() string { return "random_order" } // 策略2:区间采样(数据量 > 10 万时推荐) type RangeSamplingStrategy struct { bucketSize int64 } func (s *RangeSamplingStrategy) GetRandomAssetIDs(ctx context.Context, starID string, limit int) ([]int64, error) { // 1. 获取 ID 范围 minID, maxID, err := repo.GetIDRange(starID) if err != nil { return nil, err } // 2. 计算桶信息并随机选择桶 bucketSize := s.bucketSize // 默认 10000 totalBuckets := (maxID - minID) / bucketSize + 1 randomBucket := random.Int63n(totalBuckets) bucketStartID := minID + randomBucket*bucketSize bucketEndID := bucketStartID + bucketSize // 3. 在桶内查询 ID 列表 ids, err := repo.GetAssetIDsInRange(starID, bucketStartID, bucketEndID, limit*3) if err != nil { return nil, err } // 4. 应用层随机打乱 shuffle(ids) if len(ids) > limit { ids = ids[:limit] } return ids, nil } func (s *RangeSamplingStrategy) Name() string { return "range_sampling" } ``` **策略选择建议:** | 数据量 | 方案 | 随机性 | 推荐度 | |--------|------|--------|--------| | < 10 万 | RandomOrderStrategy | ✅ 真正随机 | 强烈推荐 | | > 10 万 | RangeSamplingStrategy | ✅ 跨桶分布均匀 | 强烈推荐 | **策略切换配置:** ```go // config.go // 数据量 < 10 万(推荐) var randomStrategy RandomStrategy = &RandomOrderStrategy{} // 数据量 > 10 万(推荐) // var randomStrategy RandomStrategy = &RangeSamplingStrategy{bucketSize: 10000} ``` > **扩展提示:** 切换策略只需修改配置,将 `randomStrategy` 的具体实现替换,接口和调用方无需改动。 --- ## 六、数据模型 ### 6.1 Exhibition 表(已有字段) ```go type Exhibition struct { ID int64 `gorm:"primaryKey"` AssetID int64 `gorm:"not null"` SlotID int64 `gorm:"not null"` HostProfileID int64 `gorm:"not null"` OccupierUID int64 `gorm:"not null"` OccupierStarID int64 `gorm:"not null;index"` StartTime int64 `gorm:"not null"` ExpireAt int64 `gorm:"not null;index"` CreatedAt int64 `gorm:"not null"` UpdatedAt int64 `gorm:"not null"` DeletedAt *int64 `gorm:"index"` } ``` ### 6.2 Asset 表关联字段 ```go type Asset struct { // ... 现有字段 ... MaterialType string `gorm:"column:material_type"` // 素材类型:badge/poster/original IsOriginal bool `gorm:"column:is_original"` LikeCount int32 `gorm:"not null;default:0"` } ``` --- ## 七、配置项 | 配置项 | 说明 | 默认值 | |--------|------|--------| | inspiration_flow_limit | 默认每页数量 | 10 | | inspiration_flow_max_limit | 最大每页数量 | 20 | --- ## 八、项目文件结构 ``` backend/ ├── proto/ │ └── gallery.proto # 修改:新增 GetInspirationFlow 方法 ├── pkg/proto/ │ ├── gallery/ │ │ ├── gallery.pb.go # 重新生成 │ │ └── gallery.triple.go # 重新生成 ├── services/galleryService/ │ ├── repository/ │ │ └── gallery_repository.go # 修改:新增 GetInspirationFlow 方法,使用 ORDER BY RANDOM() │ │ │ ├── service/ │ │ └── gallery_service.go # 修改:新增 GetInspirationFlow 方法 │ │ │ ├── provider/ │ │ └── gallery_provider.go # 修改:新增 GetInspirationFlow Handler │ │ │ └── config/ │ └── gallery_config.go # 修改:新增灵感瀑布配置项 └── gateway/ ├── controller/ │ └── gallery_controller.go # 修改:新增 GetInspirationFlow 路由处理 │ ├── dto/ │ ├── gallery_dto.go # 修改:新增 InspirationFlow DTO │ └── gallery_converter.go # 修改:新增转换函数 │ └── router/ └── router.go # 修改:新增 /api/v1/inspiration-flow 路由 ``` --- ## 九、数据库变更(必须执行) **注意:** 当前 `assets` 表**没有** `material_type` 字段,此变更**必须执行**后才能支持按类型过滤。 ### 9.1 DDL ```sql -- assets 表新增 material_type 字段 ALTER TABLE assets ADD COLUMN IF NOT EXISTS material_type VARCHAR(50) DEFAULT 'original'; -- 创建索引优化查询 CREATE INDEX IF NOT EXISTS idx_assets_material_type ON assets(material_type); ``` ### 9.2 迁移脚本 ```sql -- backend/scripts/migrate_add_material_type.sql ALTER TABLE assets ADD COLUMN IF NOT EXISTS material_type VARCHAR(50) DEFAULT 'original'; CREATE INDEX IF NOT EXISTS idx_assets_material_type ON assets(material_type); ``` --- ## 十、前端对接说明 ### 10.1 首次请求 ``` GET /api/v1/inspiration-flow?limit=10&type=all ``` ### 10.2 向右滚动(加载新数据) ``` GET /api/v1/inspiration-flow?cursor=eyJsaW1pdCI6MTB9&limit=10&type=all&direction=right ``` ### 10.3 向左滚动(加载历史) ``` GET /api/v1/inspiration-flow?cursor=eyJsaW1pdCI6MTB9&limit=10&type=all&direction=left ``` ### 10.4 前端逻辑 1. 首次请求 cursor 为空(`?cursor=` 不传或传空),direction 默认为 right 2. 解析响应中的 cursor 和 has_more 3. 滚动到右侧边缘时,若 has_more=true,携带 cursor 发起下一页请求(direction=right) 4. 滚动到左侧边缘时,携带 cursor 发起上一页请求(direction=left) 5. 会话级缓存,刷新后数据重新随机 ### 10.5 双向滚动行为说明 - **首次进入**:看到随机顺序的藏品 - **向右滚动**:加载更多新数据(发现新内容) - **向左滚动**:加载之前看过的历史数据(回看) - **刷新页面**:数据重新随机,已浏览历史清空 - **首次进入时左滑**:前端禁用左滑操作(因为没有历史数据) **前端实现要点:** ```javascript // 判断是否可以左滑 const canScrollLeft = displayedIDs.length > 0; // 首次进入时禁用左滑 if (!canScrollLeft) { // 禁用左滑手势 } // 加载数据后启用左滑 onDataLoaded() { this.canScrollLeft = true; } ``` ### 10.6 随机展示行为说明 - **每次进入页面**:看到的是新的随机顺序 - **滚动加载过程中**:顺序可能跳变(因为每次都是独立随机) - **刷新页面**:随机顺序重新生成,已浏览历史清空 --- ## 十一、待确认事项 1. **material_type 枚举值**:badge/poster/original,后续按需扩展 2. **每页数量上限**:当前设为 20(移动端优化),是否合适? 3. **是否需要缓存**:热门 star_id 的数据可以考虑 Redis 缓存 --- ## 十二、变更记录 | 日期 | 变更内容 | |------|---------| | 2026-04-29 | 重构随机查询逻辑,明确使用 ORDER BY RANDOM() 实现随机展示;添加大数据量扩展说明 | | 2026-04-29 | 针对移动端优化:默认每页数量从 20 调整为 10,最大每页数量从 50 调整为 20 | | 2026-04-29 | 补充大数据量优化方案:增加"区间采样"方案及 RandomStrategy 扩展接口设计 | | 2026-04-29 | 移除方案一(随机起点),只保留 ORDER BY RANDOM() 和区间采样两个方案 | | 2026-04-29 | 明确使用 PostgreSQL 数据库,ORDER BY RANDOM() 语法与 MySQL 相同 | | 2026-04-29 | 改用随机 offset 方案(方案 B),每次请求都是独立随机,数据变化无影响 | | 2026-04-29 | 新增双向滚动支持:向右加载新数据,向左加载历史数据,后端维护会话级缓存 | | 2026-04-29 | 修复:修正重复的 10.4 章节、RangeSamplingStrategy 签名、游标结构说明 | | 2026-04-29 | 新增 Redis 会话级缓存实现方案:支持双向滚动去重 | --- ## 十三、Redis 会话级缓存实现 ### 13.1 技术选型 - **客户端**: `github.com/redis/go-redis/v9` - **连接信息**: `localhost:6379`,无密码 - **TTL**: 30分钟(无操作自动清理) ### 13.2 缓存结构 ``` Key: inspiration_flow:{star_id}:{session_id} Type: Hash Fields: - displayed_ids: ["id1", "id2", ...] # 已展示ID列表(用于去重) - history: {"id1": json_data1, "id2": json_data2, ...} # 历史数据详情 TTL: 1800秒(30分钟) ``` ### 13.3 环境变量配置 | 变量名 | 说明 | 默认值 | |--------|------|--------| | REDIS_HOST | Redis 主机地址 | 127.0.0.1 | | REDIS_PORT | Redis 端口 | 6379 | | REDIS_PASSWORD | Redis 密码 | (空) | | REDIS_DB | Redis 数据库编号 | 0 | ### 13.4 核心逻辑 | 方向 | 行为 | |------|------| | `direction=right` | 随机查询新数据(排除已展示ID),返回并更新缓存 | | `direction=left` | 从缓存的历史数据中分页返回 | ### 13.5 实现文件 | 文件 | 说明 | |------|------| | `backend/pkg/database/redis.go`(新建) | Redis 客户端初始化 | | `backend/services/socialService/repository/social_repository.go` | 新增 `GetRandomUsersExcludeIDs` | | `backend/services/socialService/service/friend_service.go` | 修改 `GetRandomUsers` 支持 direction + 缓存 | | `backend/services/socialService/provider/social_provider.go` | 透传 direction 参数 | | `backend/gateway/dto/social_converter.go` | 转换 exclude_ids | | `backend/gateway/config/config.go` | 新增 Redis 配置 | ### 13.6 session_id 生成 - 由后端生成(UUID) - 首次请求时返回给前端,前端后续请求携带 ### 13.7 向左滚动实现 向左滚动时,后端从 Redis 缓存的 `history` 字段读取已展示数据,按 offset 分页返回。前端在左侧插入展示。 ### 13.8 预签名 URL 批量获取优化 **问题**:前端逐个获取预签名 URL,N 个卡片产生 N 次请求。 **解决方案**:新增批量接口,前端一次性获取所有 URL。 **接口设计**: ``` POST /api/v1/assets/oss/batch-presigned-urls Content-Type: application/json Request: { "files": ["path/to/img1.png", "path/to/img2.png", ...], "expires": 3600, "type": "asset" } Response: { "code": 200, "data": { "urls": { "path/to/img1.png": "https://xxx?signature=...", "path/to/img2.png": "https://xxx?signature=..." } } } ``` **前端逻辑**: 1. 加载用户数据后,收集所有 `cover_url` 2. 批量调用接口获取全部预签名 URL 3. 存入 Map 缓存,后续直接使用 **实现文件**: | 文件 | 说明 | |------|------| | `backend/gateway/controller/asset_controller.go` | 新增 batch presigned urls 路由 | | `backend/gateway/dto/asset_dto.go` | 新增 BatchPresignedUrlsRequest/Response | | `frontend/utils/api.js` | 新增 `getBatchOssPresignedUrlsApi` | | `frontend/pages/square/components/WaterfallGrid.vue` | 使用批量接口替代逐个调用 |