topfans/docs/superpowers/specs/2026-04-27-inspiration-flow-design.md
2026-04-30 21:21:12 +08:00

23 KiB
Raw Blame History

灵感瀑布流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 响应:

{
  "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

// 获取灵感瀑布藏品列表请求
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 中新增方法:

// 展馆服务
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。

游标结构JSONbase64 编码):

{
  "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 查询:

-- 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。

编码(服务端):

limit := 10  // 默认值,实际从请求或配置获取
cursor := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"limit":%d}`, limit)))

解码(服务端):

decoded, _ := base64.StdEncoding.DecodeString(cursor)
var cursorData map[string]int
json.Unmarshal(decoded, &cursorData)
limit := cursorData["limit"]  // offset 由每次请求随机生成,无需从游标获取

前端使用:

// 首次请求
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" - 向左滚动,加载历史数据(回看)

游标结构JSONbase64 编码):

{
  "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查询

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 万以上)
  • 需要保证随机分布的均匀性

实现示意:

// 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

扩展接口设计(预留)

两种策略可以抽象成统一的接口,便于后期切换:

// 随机策略接口
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. 随机生成 offset0 ~ 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 跨桶分布均匀 强烈推荐

策略切换配置:

// config.go
// 数据量 < 10 万(推荐)
var randomStrategy RandomStrategy = &RandomOrderStrategy{}

// 数据量 > 10 万(推荐)
// var randomStrategy RandomStrategy = &RangeSamplingStrategy{bucketSize: 10000}

扩展提示: 切换策略只需修改配置,将 randomStrategy 的具体实现替换,接口和调用方无需改动。


六、数据模型

6.1 Exhibition 表(已有字段)

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 表关联字段

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

-- 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 迁移脚本

-- 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 双向滚动行为说明

  • 首次进入:看到随机顺序的藏品
  • 向右滚动:加载更多新数据(发现新内容)
  • 向左滚动:加载之前看过的历史数据(回看)
  • 刷新页面:数据重新随机,已浏览历史清空
  • 首次进入时左滑:前端禁用左滑操作(因为没有历史数据)

前端实现要点:

// 判断是否可以左滑
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 批量获取优化

问题:前端逐个获取预签名 URLN 个卡片产生 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 使用批量接口替代逐个调用